Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2eb5ec47 | |||
| 8358b98111 | |||
| af3aefe830 | |||
| ebc54a4481 | |||
| 8cf7ccd5ee | |||
| a9395e9f73 | |||
| b9df31356f | |||
| d2abd0e279 | |||
| 8a6ce3b21d | |||
| 239850c52d | |||
| 6b84105e79 | |||
| c159db5f0b | |||
| a18edf03ec | |||
| 18acf6722b | |||
| 5e0a4c43e6 | |||
| 48f04a0a7d | |||
| ae1753bcee | |||
| 3ae53dd719 | |||
| 3e0ed70362 | |||
| c6c64fdd86 | |||
| 616dd91483 | |||
| f952961eea | |||
| 3ca08f781b | |||
| 03011eea97 | |||
| 3783125b6a | |||
| fe6549e5f5 | |||
| cedb384dc4 | |||
| 163452a398 | |||
| f348b57721 | |||
| df5a60479c | |||
| 9c665d42a9 | |||
| 49f625a1db | |||
| af38e3a0c0 | |||
| 104749063d | |||
| d7b896e3d8 | |||
| 5693bcc4e4 | |||
| de70cbe389 | |||
| 73099aa67a | |||
| 9266f89776 | |||
| f88d7a1066 | |||
| f3e61606bd | |||
| 95794779d6 | |||
| e3f4566f6f | |||
| b86831d2ba | |||
| 4917984385 | |||
| c3b1c76151 | |||
| 7897a1555e | |||
| 523ac9f35e | |||
| 6e65b33161 | |||
| be2a9e0047 | |||
| fad2ea7763 | |||
| 4e7b6eea7b | |||
| a0704d5c7b | |||
| 63fa7c9e04 | |||
| 237e916390 | |||
| 6a16e35eb1 | |||
| 15b6abf843 | |||
| 540ace4e84 | |||
| c0a38587e2 | |||
| 2b1d9e889e | |||
| 5cd63429a9 | |||
| 820831b03d | |||
| 8cde0e7e48 | |||
| bd4651fb0b | |||
| 8d810b7a0b | |||
| c193e601f3 | |||
| 78ff45fdaf | |||
| ac42ce868a | |||
| 84ac9e51ac | |||
| 3062410b42 |
@@ -13,15 +13,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
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: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- run: corepack enable
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile --prefer-offline
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
@@ -31,9 +34,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: 1.26.4
|
go-version: 1.26.5
|
||||||
check-latest: true
|
check-latest: true
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
@@ -51,15 +54,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
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: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- run: corepack enable
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -93,14 +99,14 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4.2.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v4.2.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -124,9 +130,10 @@ jobs:
|
|||||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7.2.0
|
uses: docker/build-push-action@v7.3.0
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
|
sbom: true
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -145,10 +152,8 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
name: Install pnpm
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
- name: Release to Github
|
- name: Release to Github
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ jobs:
|
|||||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4.2.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v4.2.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7.2.0
|
uses: docker/build-push-action@v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||||
- uses: pnpm/action-setup@v6
|
- name: Install Node
|
||||||
- name: Setup Node
|
uses: actions/setup-node@v7
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
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
|
cache: pnpm # or pnpm / yarn
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v6
|
uses: actions/configure-pages@v6
|
||||||
|
|||||||
@@ -16,17 +16,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -38,13 +37,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
name: Install Node
|
||||||
|
with:
|
||||||
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -56,9 +58,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.4"
|
go-version: "1.26.5"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
@@ -79,9 +81,9 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.4"
|
go-version: "1.26.5"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
- name: Generate dependencies
|
- name: Generate dependencies
|
||||||
run: make fake_assets shared_key.pem shared_cert.pem
|
run: make fake_assets shared_key.pem shared_cert.pem
|
||||||
@@ -95,17 +97,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
|
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||||
const EffectScope: typeof import('vue').EffectScope
|
const EffectScope: typeof import('vue').EffectScope
|
||||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
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 acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||||
const allLevels: typeof import('./composable/logContext').allLevels
|
const allLevels: typeof import('./composable/logContext').allLevels
|
||||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||||
@@ -17,6 +19,7 @@ declare global {
|
|||||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||||
|
const colorize: typeof import('./utils/index').colorize
|
||||||
const compact: typeof import('./stores/settings').compact
|
const compact: typeof import('./stores/settings').compact
|
||||||
const computed: typeof import('vue').computed
|
const computed: typeof import('vue').computed
|
||||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||||
@@ -66,8 +69,10 @@ declare global {
|
|||||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||||
const getDeep: typeof import('./utils/index').getDeep
|
const getDeep: typeof import('./utils/index').getDeep
|
||||||
|
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
|
||||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||||
|
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
|
||||||
const h: typeof import('vue').h
|
const h: typeof import('vue').h
|
||||||
const hashCode: typeof import('./utils/index').hashCode
|
const hashCode: typeof import('./utils/index').hashCode
|
||||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||||
@@ -117,6 +122,7 @@ declare global {
|
|||||||
const onUnmounted: typeof import('vue').onUnmounted
|
const onUnmounted: typeof import('vue').onUnmounted
|
||||||
const onUpdated: typeof import('vue').onUpdated
|
const onUpdated: typeof import('vue').onUpdated
|
||||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||||
|
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
|
||||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||||
@@ -213,6 +219,7 @@ declare global {
|
|||||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||||
|
const useCommands: typeof import('./composable/commands').useCommands
|
||||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||||
@@ -417,6 +424,9 @@ declare global {
|
|||||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||||
import('./composable/cloudLogSearch')
|
import('./composable/cloudLogSearch')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
export type { CommandSection, Command } from './composable/commands'
|
||||||
|
import('./composable/commands')
|
||||||
|
// @ts-ignore
|
||||||
export type { DrawerWidth } from './composable/drawer'
|
export type { DrawerWidth } from './composable/drawer'
|
||||||
import('./composable/drawer')
|
import('./composable/drawer')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -435,7 +445,7 @@ declare global {
|
|||||||
export type { Host } from './stores/hosts'
|
export type { Host } from './stores/hosts'
|
||||||
import('./stores/hosts')
|
import('./stores/hosts')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||||
import('./stores/k8s')
|
import('./stores/k8s')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export type { Settings } from './stores/settings'
|
export type { Settings } from './stores/settings'
|
||||||
@@ -458,6 +468,7 @@ declare module 'vue' {
|
|||||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
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 compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||||
@@ -506,8 +517,10 @@ declare module 'vue' {
|
|||||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||||
|
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
|
||||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||||
|
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||||
@@ -556,6 +569,7 @@ declare module 'vue' {
|
|||||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||||
|
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
|
||||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||||
@@ -649,6 +663,7 @@ declare module 'vue' {
|
|||||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||||
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
||||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
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 useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -105,6 +105,8 @@ declare module 'vue' {
|
|||||||
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
||||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['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:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||||
'Mdi:bell': typeof import('~icons/mdi/bell')['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:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||||
'Ph:database': typeof import('~icons/ph/database')['default']
|
'Ph:database': typeof import('~icons/ph/database')['default']
|
||||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['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:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||||
'Ph:stack': typeof import('~icons/ph/stack')['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 { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { copy, copied, isSupported } = useClipboard();
|
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showToast, removeToast } = useToast();
|
const { showToast, removeToast } = useToast();
|
||||||
|
|
||||||
@@ -366,10 +366,25 @@ a {
|
|||||||
@apply whitespace-nowrap;
|
@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 {
|
.menu li ul {
|
||||||
margin-inline-start: 0;
|
margin-inline-start: 0;
|
||||||
|
width: 100%;
|
||||||
&:before {
|
&:before {
|
||||||
display: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -46,8 +46,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||||
<VolumeWarning :container="container" />
|
<VolumeWarning :container="container" />
|
||||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
<Tag
|
||||||
{{ container.image.replace(/@sha.*/, "") }}
|
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>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,6 +68,21 @@
|
|||||||
import { Container } from "@/models/Container";
|
import { Container } from "@/models/Container";
|
||||||
|
|
||||||
const { container } = defineProps<{ container: 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({
|
const pinned = computed({
|
||||||
get: () => pinnedContainers.value.has(container.name),
|
get: () => pinnedContainers.value.has(container.name),
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
|
|||||||
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
||||||
|
|
||||||
import { Container } from "@/models/Container";
|
import { Container } from "@/models/Container";
|
||||||
|
import { lightTheme } from "@/stores/settings";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
@@ -16,7 +17,7 @@ vi.mock("vue-router");
|
|||||||
|
|
||||||
vi.mock("@/stores/config", () => ({
|
vi.mock("@/stores/config", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
|
||||||
withBase: (path: string) => path,
|
withBase: (path: string) => path,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -119,4 +120,28 @@ describe("<FuzzySearchModal />", () => {
|
|||||||
await wrapper.find("input").trigger("keydown.enter");
|
await wrapper.find("input").trigger("keydown.enter");
|
||||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
||||||
ref="input"
|
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.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||||
@keydown.enter.exact="onEnter"
|
@keydown.enter.exact="onEnter"
|
||||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
@keydown.alt.enter.exact.prevent="onPin"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
:placeholder="placeholderCopy"
|
:placeholder="placeholderCopy"
|
||||||
/>
|
/>
|
||||||
@@ -29,51 +29,80 @@
|
|||||||
|
|
||||||
<!-- Body: results + log search CTA. Only renders when there is something
|
<!-- Body: results + log search CTA. Only renders when there is something
|
||||||
to show — keeps the empty modal compact. -->
|
to show — keeps the empty modal compact. -->
|
||||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
<div v-if="totalCount || logSearchVisible" class="border-base-content/10 border-t">
|
||||||
<!-- Containers section -->
|
<!-- Scroll container spans both sections so the flat Commands + Containers
|
||||||
<template v-if="results.length">
|
list scrolls as one, matching the unified selection index. -->
|
||||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
<div class="max-h-[50vh] overflow-y-auto overscroll-contain">
|
||||||
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
|
<!-- Commands section -->
|
||||||
</div>
|
<template v-if="commandEntries.length">
|
||||||
<ul class="pb-1">
|
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||||
<li v-for="(result, index) in data" ref="listItems">
|
{{ $t("command-palette.section-commands") }} · {{ commandEntries.length }}
|
||||||
<a
|
</div>
|
||||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
<ul class="pb-1">
|
||||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
<li v-for="(command, index) in commandEntries" :ref="(el) => setItemRef(el, index)">
|
||||||
@click.prevent="selected(result.item)"
|
<a
|
||||||
>
|
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||||
<template v-if="result.item.type === 'container'">
|
@click.prevent="runCommand(command)"
|
||||||
<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="index === selectedIndex" class="size-4" />
|
<component :is="command.icon" class="text-base-content/60 size-4 shrink-0" />
|
||||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
<span class="min-w-0 flex-1 truncate text-sm">{{ command.title }}</span>
|
||||||
</span>
|
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="text-base-content/40 size-4" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</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 -->
|
<!-- Log search CTA -->
|
||||||
<div
|
<div
|
||||||
v-if="logSearchVisible"
|
v-if="logSearchVisible"
|
||||||
@@ -130,7 +159,7 @@
|
|||||||
<div
|
<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"
|
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") }}
|
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
<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 { type FuseResult } from "fuse.js";
|
||||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||||
|
import { useCommands, type Command } from "@/composable/commands";
|
||||||
|
|
||||||
const close = defineEmit();
|
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 initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
||||||
const query = ref(initialQuery);
|
const query = ref(initialQuery);
|
||||||
const input = ref<HTMLInputElement>();
|
const input = ref<HTMLInputElement>();
|
||||||
const listItems = ref<HTMLInputElement[]>();
|
const listItems = ref<(Element | null)[]>([]);
|
||||||
const selectedIndex = ref(0);
|
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 containerStore = useContainerStore();
|
||||||
const pinnedStore = usePinnedLogsStore();
|
const pinnedStore = usePinnedLogsStore();
|
||||||
const { visibleContainers } = storeToRefs(containerStore);
|
const { visibleContainers } = storeToRefs(containerStore);
|
||||||
@@ -268,6 +304,20 @@ const { results: fuseResults } = useFuse(query, list, {
|
|||||||
|
|
||||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
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(() => {
|
const data = computed(() => {
|
||||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||||
if (a.score === b.score) {
|
if (a.score === b.score) {
|
||||||
@@ -284,14 +334,26 @@ const data = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(query, (data) => {
|
// Container hits, mirrors the previously named `data` list for the template.
|
||||||
if (data.length > 0) {
|
const containerEntries = computed(() => data.value);
|
||||||
selectedIndex.value = 0;
|
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, () => {
|
watch(selectedIndex, () => {
|
||||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
listItems.value?.[selectedIndex.value]?.scrollIntoView({ block: "nearest" });
|
||||||
});
|
});
|
||||||
|
|
||||||
function selected(item: Item) {
|
function selected(item: Item) {
|
||||||
@@ -305,17 +367,35 @@ function selected(item: Item) {
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCommand(command: Command) {
|
||||||
|
close();
|
||||||
|
await command.perform();
|
||||||
|
}
|
||||||
|
|
||||||
function onEnter() {
|
function onEnter() {
|
||||||
// Plain Enter prefers a container match if one is selected. With no
|
// Commands come first in the flat list, then containers. With nothing
|
||||||
// container matches (cloud-only query like "OOM"), fall back to log search
|
// selectable (cloud-only query like "OOM"), fall back to log search so the
|
||||||
// so the user isn't stuck on a popup that does nothing.
|
// user isn't stuck on a popup that does nothing.
|
||||||
if (data.value.length > 0) {
|
const commandCount = commandEntries.value.length;
|
||||||
selected(data.value[selectedIndex.value].item);
|
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) {
|
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
||||||
runLogSearch();
|
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() {
|
function runLogSearch() {
|
||||||
if (!cloudSearch.available.value) return;
|
if (!cloudSearch.available.value) return;
|
||||||
const q = query.value.trim();
|
const q = query.value.trim();
|
||||||
|
|||||||
@@ -78,8 +78,8 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</summary>
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
<li v-for="owner in owners" :key="owner.key">
|
||||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||||
<ph:stack-simple />
|
<ph:stack-simple />
|
||||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||||
</summary>
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
<li v-for="owner in ownersWithoutNamespace" :key="owner.key">
|
||||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||||
<ph:stack-simple />
|
<ph:stack-simple />
|
||||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<mdi:cog class="size-6" />
|
<mdi:cog class="size-6" />
|
||||||
</router-link>
|
</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 #trigger>
|
||||||
<template v-if="config.disableAvatars || !config.user.email">
|
<template v-if="config.disableAvatars || !config.user.email">
|
||||||
<material-symbols:person class="size-6" />
|
<material-symbols:person class="size-6" />
|
||||||
|
|||||||
@@ -14,13 +14,6 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
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<{
|
const { logEntry } = defineProps<{
|
||||||
logEntry: GroupedLogEntry;
|
logEntry: GroupedLogEntry;
|
||||||
@@ -32,6 +25,4 @@ const getPosition = (index: number): Position => {
|
|||||||
if (index === len - 1) return "end";
|
if (index === len - 1) return "end";
|
||||||
return "middle";
|
return "middle";
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@mouseenter="checkDropdownPosition"
|
@mouseenter="checkDropdownPosition"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isSearching"
|
v-if="isFiltered"
|
||||||
@click="resetSearch()"
|
@click="resetSearch()"
|
||||||
tabindex="0"
|
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"
|
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"
|
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"
|
@click="hideMenu"
|
||||||
>
|
>
|
||||||
<li v-if="isSearching">
|
<li v-if="isFiltered">
|
||||||
<router-link
|
<router-link
|
||||||
@click="resetSearch()"
|
@click="resetSearch()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -99,8 +99,13 @@ const { showToast } = useToast();
|
|||||||
const showDrawer = useDrawer();
|
const showDrawer = useDrawer();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSearching, resetSearch } = useSearchFilter();
|
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();
|
const { t } = useI18n();
|
||||||
|
|
||||||
async function copyLogMessage() {
|
async function copyLogMessage() {
|
||||||
|
|||||||
@@ -48,6 +48,25 @@
|
|||||||
}}</template>
|
}}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -238,5 +257,54 @@ whenever(evaluating, () => {
|
|||||||
const page = computed(() =>
|
const page = computed(() =>
|
||||||
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
||||||
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
) 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>
|
</script>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -9,17 +9,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
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<{
|
defineProps<{
|
||||||
logEntry: SimpleLogEntry;
|
logEntry: SimpleLogEntry;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
|||||||
</li>
|
</li>
|
||||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
<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 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">
|
<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">
|
||||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
<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>
|
||||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
</svg></a>
|
||||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
|
||||||
</svg></button>
|
|
||||||
<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">
|
<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 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">
|
||||||
<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">
|
<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>
|
<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>
|
</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>
|
<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>
|
</svg> action.copy-link</a></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
@@ -48,17 +48,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
|||||||
</li>
|
</li>
|
||||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
<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 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">
|
<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">
|
||||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
<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>
|
||||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
</svg></a>
|
||||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
|
||||||
</svg></button>
|
|
||||||
<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">
|
<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 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">
|
||||||
<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">
|
<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>
|
<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>
|
</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>
|
<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>
|
</svg> action.copy-link</a></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
@@ -89,17 +89,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
|||||||
</li>
|
</li>
|
||||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
<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 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">
|
<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">
|
||||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
<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>
|
||||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
</svg></a>
|
||||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
|
||||||
</svg></button>
|
|
||||||
<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">
|
<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 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">
|
||||||
<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">
|
<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>
|
<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>
|
</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>
|
<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>
|
</svg> action.copy-link</a></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||||
<header
|
<header
|
||||||
v-if="$slots.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>
|
<slot name="header"></slot>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
|
<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="p-2">
|
<div class="px-4 py-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<router-link :to="{ name: '/' }">
|
<router-link :to="{ name: '/' }">
|
||||||
<Logo class="h-10" />
|
<Logo class="h-10" />
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
<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">
|
<div class="pt-safe relative">
|
||||||
<form method="dialog" class="absolute right-0">
|
<div class="absolute right-0 flex items-center gap-3">
|
||||||
<button v-if="isMobile">
|
<button
|
||||||
<mdi:close />
|
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>
|
||||||
<button v-else class="swap hover:swap-active outline-hidden">
|
<form method="dialog">
|
||||||
<mdi:keyboard-esc class="swap-off" />
|
<button v-if="isMobile" class="cursor-pointer">
|
||||||
<mdi:close class="swap-on" />
|
<mdi:close />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<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>
|
<slot v-if="open" :close="close"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,6 +37,7 @@ import { type DrawerWidth } from "@/composable/drawer";
|
|||||||
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
|
const maximized = ref(false);
|
||||||
const { width } = defineProps<{
|
const { width } = defineProps<{
|
||||||
width: DrawerWidth;
|
width: DrawerWidth;
|
||||||
}>();
|
}>();
|
||||||
@@ -35,6 +49,7 @@ function close() {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
open: () => {
|
open: () => {
|
||||||
open.value = true;
|
open.value = true;
|
||||||
|
maximized.value = false;
|
||||||
panel.value?.showModal();
|
panel.value?.showModal();
|
||||||
},
|
},
|
||||||
close,
|
close,
|
||||||
@@ -55,6 +70,10 @@ useEventListener(panel, "close", () => (open.value = false));
|
|||||||
&[width="lg"] {
|
&[width="lg"] {
|
||||||
@apply max-w-5xl;
|
@apply max-w-5xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[width="full"] {
|
||||||
|
@apply w-full max-w-full;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-right[open] .modal-box {
|
.modal-right[open] .modal-box {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -57,12 +57,12 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
|
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
|
||||||
const labels = computed(() => `namespace:${namespace.value.name}`);
|
const labels = computed(() => `@k8s.namespace:${namespace.value.name}`);
|
||||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
|
export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
|
||||||
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
|
const labels = computed(() => `${owner.value.label}:true`);
|
||||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
|
<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" />
|
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<form method="dialog" class="modal-backdrop">
|
||||||
@@ -100,7 +100,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
|
|||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.router-view {
|
.router-view {
|
||||||
padding-top: 75px;
|
padding-top: var(--mobile-nav-height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,6 +57,24 @@
|
|||||||
--color-error: var(--color-red);
|
--color-error: var(--color-red);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--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-selector: 1rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.25rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.5rem;
|
||||||
@@ -97,6 +115,24 @@
|
|||||||
--color-error: var(--color-red);
|
--color-error: var(--color-red);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--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-selector: 1rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.25rem;
|
||||||
--radius-box: 0.5rem;
|
--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 {
|
@utility menu-active {
|
||||||
&,
|
&,
|
||||||
&: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 {
|
body {
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
@@ -150,6 +202,13 @@ body {
|
|||||||
padding-right: env(safe-area-inset-right);
|
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-"] {
|
[class*="shadow-"] {
|
||||||
@apply shadow-base-content/8;
|
@apply shadow-base-content/8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
|||||||
|
|
||||||
const k8sStore = useK8sStore();
|
const k8sStore = useK8sStore();
|
||||||
const { owners } = storeToRefs(k8sStore);
|
const { owners } = storeToRefs(k8sStore);
|
||||||
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
|
const ownerKey = computed(() => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(String(route.params.name));
|
||||||
|
} catch {
|
||||||
|
return String(route.params.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const owner = computed(() => owners.value.find((o) => o.key === ownerKey.value));
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (ready.value) {
|
if (ready.value) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Container } from "@/models/Container";
|
||||||
|
import { getK8sOwnerRefs, groupK8sOwners, ownerMembershipLabel } from "./k8s";
|
||||||
|
|
||||||
|
vi.mock("@/stores/config", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||||
|
withBase: (path: string) => path,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContainer(id: string, labels: Record<string, string>) {
|
||||||
|
return new Container(
|
||||||
|
id,
|
||||||
|
new Date(),
|
||||||
|
new Date(),
|
||||||
|
new Date(),
|
||||||
|
"image",
|
||||||
|
id,
|
||||||
|
"command",
|
||||||
|
"localhost",
|
||||||
|
labels,
|
||||||
|
"running",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getK8sOwnerRefs", () => {
|
||||||
|
test("parses indexed owner-chain labels", () => {
|
||||||
|
const container = makeContainer("api", {
|
||||||
|
namespace: "default",
|
||||||
|
"@k8s.owner.count": "2",
|
||||||
|
"@k8s.owner.0.kind": "ReplicaSet",
|
||||||
|
"@k8s.owner.0.namespace": "default",
|
||||||
|
"@k8s.owner.0.name": "api-6f88b977f4",
|
||||||
|
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
|
||||||
|
"@k8s.owner.1.kind": "Deployment",
|
||||||
|
"@k8s.owner.1.namespace": "default",
|
||||||
|
"@k8s.owner.1.name": "api",
|
||||||
|
"@k8s.owner.1.key": "Deployment~default~api",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getK8sOwnerRefs(container)).toEqual([
|
||||||
|
{
|
||||||
|
key: "ReplicaSet~default~api-6f88b977f4",
|
||||||
|
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
|
||||||
|
kind: "ReplicaSet",
|
||||||
|
name: "api-6f88b977f4",
|
||||||
|
namespace: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Deployment~default~api",
|
||||||
|
label: ownerMembershipLabel("Deployment~default~api"),
|
||||||
|
kind: "Deployment",
|
||||||
|
name: "api",
|
||||||
|
namespace: "default",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to legacy immediate owner labels", () => {
|
||||||
|
const container = makeContainer("api", {
|
||||||
|
namespace: "default",
|
||||||
|
"owner.kind": "ReplicaSet",
|
||||||
|
"owner.name": "api-6f88b977f4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getK8sOwnerRefs(container)).toEqual([
|
||||||
|
{
|
||||||
|
key: "ReplicaSet~default~api-6f88b977f4",
|
||||||
|
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
|
||||||
|
kind: "ReplicaSet",
|
||||||
|
name: "api-6f88b977f4",
|
||||||
|
namespace: "default",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses kind for display and type for CRD identity", () => {
|
||||||
|
const container = makeContainer("rollout-api", {
|
||||||
|
namespace: "default",
|
||||||
|
"@k8s.owner.count": "1",
|
||||||
|
"@k8s.owner.0.kind": "Rollout",
|
||||||
|
"@k8s.owner.0.namespace": "default",
|
||||||
|
"@k8s.owner.0.name": "api",
|
||||||
|
"@k8s.owner.0.key": "argoproj.io~v1alpha1~Rollout~default~api",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getK8sOwnerRefs(container)).toEqual([
|
||||||
|
{
|
||||||
|
key: "argoproj.io~v1alpha1~Rollout~default~api",
|
||||||
|
label: ownerMembershipLabel("argoproj.io~v1alpha1~Rollout~default~api"),
|
||||||
|
kind: "Rollout",
|
||||||
|
name: "api",
|
||||||
|
namespace: "default",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupK8sOwners", () => {
|
||||||
|
test("groups a container under every owner in its chain", () => {
|
||||||
|
const container = makeContainer("api", {
|
||||||
|
namespace: "default",
|
||||||
|
"@k8s.owner.count": "2",
|
||||||
|
"@k8s.owner.0.kind": "ReplicaSet",
|
||||||
|
"@k8s.owner.0.namespace": "default",
|
||||||
|
"@k8s.owner.0.name": "api-6f88b977f4",
|
||||||
|
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
|
||||||
|
"@k8s.owner.1.kind": "Deployment",
|
||||||
|
"@k8s.owner.1.namespace": "default",
|
||||||
|
"@k8s.owner.1.name": "api",
|
||||||
|
"@k8s.owner.1.key": "Deployment~default~api",
|
||||||
|
});
|
||||||
|
|
||||||
|
const owners = groupK8sOwners([container]);
|
||||||
|
|
||||||
|
expect(owners.map((owner) => owner.key).sort()).toEqual([
|
||||||
|
"Deployment~default~api",
|
||||||
|
"ReplicaSet~default~api-6f88b977f4",
|
||||||
|
]);
|
||||||
|
expect(owners.every((owner) => owner.containers.length === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps same-name owners in different namespaces separate", () => {
|
||||||
|
const owners = groupK8sOwners([
|
||||||
|
makeContainer("default-api", {
|
||||||
|
namespace: "default",
|
||||||
|
"@k8s.owner.count": "1",
|
||||||
|
"@k8s.owner.0.kind": "Deployment",
|
||||||
|
"@k8s.owner.0.namespace": "default",
|
||||||
|
"@k8s.owner.0.name": "api",
|
||||||
|
"@k8s.owner.0.key": "Deployment~default~api",
|
||||||
|
}),
|
||||||
|
makeContainer("prod-api", {
|
||||||
|
namespace: "prod",
|
||||||
|
"@k8s.owner.count": "1",
|
||||||
|
"@k8s.owner.0.kind": "Deployment",
|
||||||
|
"@k8s.owner.0.namespace": "prod",
|
||||||
|
"@k8s.owner.0.name": "api",
|
||||||
|
"@k8s.owner.0.key": "Deployment~prod~api",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(owners.map((owner) => owner.key).sort()).toEqual(["Deployment~default~api", "Deployment~prod~api"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,14 @@ import { acceptHMRUpdate, defineStore } from "pinia";
|
|||||||
|
|
||||||
import { Container, GroupedContainers } from "@/models/Container";
|
import { Container, GroupedContainers } from "@/models/Container";
|
||||||
|
|
||||||
|
export type K8sOwnerRef = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class K8sNamespace {
|
export class K8sNamespace {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly name: string,
|
public readonly name: string,
|
||||||
@@ -22,6 +30,9 @@ export class K8sOwner {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly name: string,
|
public readonly name: string,
|
||||||
public readonly kind: string,
|
public readonly kind: string,
|
||||||
|
public readonly namespaceName: string | undefined,
|
||||||
|
public readonly key: string,
|
||||||
|
public readonly label: string,
|
||||||
public readonly containers: Container[],
|
public readonly containers: Container[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,6 +43,54 @@ export class K8sOwner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ownerMembershipLabel(key: string) {
|
||||||
|
const bytes = new TextEncoder().encode(key);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return `@k8s.owner.key.${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getK8sOwnerRefs(container: Container): K8sOwnerRef[] {
|
||||||
|
const count = Number(container.labels["@k8s.owner.count"] ?? 0);
|
||||||
|
if (count > 0) {
|
||||||
|
const owners: K8sOwnerRef[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const prefix = `@k8s.owner.${i}.`;
|
||||||
|
const kind = container.labels[`${prefix}kind`];
|
||||||
|
const name = container.labels[`${prefix}name`];
|
||||||
|
const namespace = container.labels[`${prefix}namespace`];
|
||||||
|
if (!kind || !name) continue;
|
||||||
|
const key = container.labels[`${prefix}key`] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||||
|
owners.push({ key, label: ownerMembershipLabel(key), kind, name, namespace });
|
||||||
|
}
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "~" matches the backend owner-key delimiter: URL-safe and invalid in Kubernetes names.
|
||||||
|
const kind = container.labels["owner.kind"];
|
||||||
|
const name = container.labels["owner.name"];
|
||||||
|
if (!kind || !name) return [];
|
||||||
|
|
||||||
|
const namespace = container.labels["namespace"];
|
||||||
|
const key = container.labels["owner.key"] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||||
|
return [{ key, label: ownerMembershipLabel(key), kind, name, namespace }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupK8sOwners(containers: Container[]) {
|
||||||
|
const ownerGroups: Record<string, { owner: K8sOwnerRef; containers: Container[] }> = {};
|
||||||
|
for (const container of containers) {
|
||||||
|
for (const owner of getK8sOwnerRefs(container)) {
|
||||||
|
ownerGroups[owner.key] ||= { owner, containers: [] };
|
||||||
|
ownerGroups[owner.key].containers.push(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(ownerGroups).map(({ owner, containers }) => {
|
||||||
|
return new K8sOwner(owner.name, owner.kind, owner.namespace, owner.key, owner.label, containers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const useK8sStore = defineStore("k8s", () => {
|
export const useK8sStore = defineStore("k8s", () => {
|
||||||
const containerStore = useContainerStore();
|
const containerStore = useContainerStore();
|
||||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||||
@@ -50,24 +109,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
|||||||
const newNamespaces: K8sNamespace[] = [];
|
const newNamespaces: K8sNamespace[] = [];
|
||||||
|
|
||||||
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
||||||
const ownerGroups: Record<string, Container[]> = {};
|
const newOwners = groupK8sOwners(containers);
|
||||||
|
|
||||||
for (const container of containers) {
|
|
||||||
const ownerKind = container.labels["owner.kind"];
|
|
||||||
const ownerName = container.labels["owner.name"];
|
|
||||||
|
|
||||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
|
||||||
const key = `${ownerKind}:${ownerName}`;
|
|
||||||
ownerGroups[key] ||= [];
|
|
||||||
ownerGroups[key].push(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOwners: K8sOwner[] = [];
|
|
||||||
|
|
||||||
for (const [key, containers] of Object.entries(ownerGroups)) {
|
|
||||||
const [kind, name] = key.split(":");
|
|
||||||
newOwners.push(new K8sOwner(name, kind, containers));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newOwners.length === 0) continue;
|
if (newOwners.length === 0) continue;
|
||||||
|
|
||||||
@@ -75,7 +117,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
|||||||
new K8sNamespace(
|
new K8sNamespace(
|
||||||
name,
|
name,
|
||||||
containers,
|
containers,
|
||||||
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
|
newOwners.sort((a, b) => a.key.localeCompare(b.key)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,33 +125,23 @@ export const useK8sStore = defineStore("k8s", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const owners = computed(() => {
|
const owners = computed(() => {
|
||||||
const ownerGroups: Record<string, Container[]> = {};
|
const containersWithoutNamespace: Container[] = [];
|
||||||
|
|
||||||
for (const container of runningContainers.value) {
|
for (const container of runningContainers.value) {
|
||||||
const ownerKind = container.labels["owner.kind"];
|
|
||||||
const ownerName = container.labels["owner.name"];
|
|
||||||
const namespace = container.labels["namespace"];
|
const namespace = container.labels["namespace"];
|
||||||
|
|
||||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
|
||||||
if (namespace) {
|
if (namespace) {
|
||||||
// Skip containers that are already part of a namespace
|
// Skip containers that are already part of a namespace
|
||||||
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
||||||
if (hasNamespace) continue;
|
if (hasNamespace) continue;
|
||||||
}
|
}
|
||||||
|
containersWithoutNamespace.push(container);
|
||||||
const key = `${ownerKind}:${ownerName}`;
|
|
||||||
ownerGroups[key] ||= [];
|
|
||||||
ownerGroups[key].push(container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
||||||
|
|
||||||
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
|
const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
|
||||||
const [kind, name] = key.split(":");
|
|
||||||
return new K8sOwner(name, kind, containers);
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
|
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
});
|
});
|
||||||
|
|
||||||
const customGroups = computed(() => {
|
const customGroups = computed(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -2,3 +2,4 @@ export { formatDuration, toRelativeTime } from "./date";
|
|||||||
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
||||||
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
||||||
export { formatBytes, stripVersion, hashCode } from "./format";
|
export { formatBytes, stripVersion, hashCode } from "./format";
|
||||||
|
export { colorize } from "./ansi";
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
x-dozzle-healthcheck: &dozzle-healthcheck
|
||||||
|
test: ["CMD", "/dozzle", "healthcheck"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
start_interval: 5s
|
||||||
|
|
||||||
services:
|
services:
|
||||||
custom_base:
|
custom_base:
|
||||||
container_name: custom_base
|
container_name: custom_base
|
||||||
@@ -10,6 +17,7 @@ services:
|
|||||||
- DOZZLE_HOSTNAME=custom name
|
- DOZZLE_HOSTNAME=custom name
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
simple-auth:
|
simple-auth:
|
||||||
@@ -20,6 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DOZZLE_AUTH_PROVIDER=simple
|
- DOZZLE_AUTH_PROVIDER=simple
|
||||||
- DOZZLE_NO_ANALYTICS=1
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dozzle:
|
dozzle:
|
||||||
@@ -33,8 +42,33 @@ services:
|
|||||||
- DOZZLE_LEVEL=debug
|
- DOZZLE_LEVEL=debug
|
||||||
ports:
|
ports:
|
||||||
- 7070:8080
|
- 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:
|
build:
|
||||||
context: .
|
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:
|
remote:
|
||||||
container_name: remote
|
container_name: remote
|
||||||
environment:
|
environment:
|
||||||
@@ -43,6 +77,7 @@ services:
|
|||||||
- DOZZLE_NO_ANALYTICS=1
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
ports:
|
ports:
|
||||||
- 5050:8080
|
- 5050:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -56,6 +91,7 @@ services:
|
|||||||
- DOZZLE_LEVEL=debug
|
- DOZZLE_LEVEL=debug
|
||||||
ports:
|
ports:
|
||||||
- 8082:8080
|
- 8082:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -85,6 +121,9 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- CONTAINERS=1
|
- CONTAINERS=1
|
||||||
|
# Dozzle calls /info on connect; without it the host never initializes and
|
||||||
|
# lists zero containers.
|
||||||
|
- INFO=1
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -96,7 +135,7 @@ services:
|
|||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
container_name: 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
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
@@ -105,6 +144,15 @@ services:
|
|||||||
- PWTEST_SKIP_TEST_OUTPUT=1
|
- PWTEST_SKIP_TEST_OUTPUT=1
|
||||||
- CI=1
|
- CI=1
|
||||||
depends_on:
|
depends_on:
|
||||||
- dozzle
|
dozzle:
|
||||||
- custom_base
|
condition: service_started
|
||||||
- remote
|
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
|
||||||
|
|||||||
@@ -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 };
|
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 {
|
export default {
|
||||||
async load() {
|
async load() {
|
||||||
const urls = [
|
const urls = [
|
||||||
@@ -8,13 +28,18 @@ export default {
|
|||||||
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
|
"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 = {
|
return {
|
||||||
stars: responses[0].stargazers_count,
|
stars: repo.stargazers_count,
|
||||||
pulls: responses[1].pull_count,
|
pulls: hub.pull_count,
|
||||||
|
contributors,
|
||||||
|
release,
|
||||||
};
|
};
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export default defineConfig({
|
|||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
|
|
||||||
head: [
|
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",
|
"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>
|
||||||
@@ -1,36 +1,49 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useMutationObserver } from "@vueuse/core";
|
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||||
import { onMounted, ref } from "vue";
|
import { useData } from "vitepress";
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
const isDark = ref(false);
|
import darkPoster from "../media/poster-dark.webp";
|
||||||
onMounted(() => {
|
import lightPoster from "../media/poster-light.webp";
|
||||||
isDark.value = document.documentElement.classList.contains("dark");
|
import darkVideo from "../media/dozzle-dark.mp4";
|
||||||
useMutationObserver(
|
import lightVideo from "../media/dozzle-light.mp4";
|
||||||
document.documentElement,
|
|
||||||
(mutations) => {
|
const { isDark } = useData();
|
||||||
isDark.value = document.documentElement.classList.contains("dark");
|
const reducedMotion = usePreferredReducedMotion();
|
||||||
},
|
|
||||||
{
|
// The posters are rendered on the server and toggled with CSS so the first paint
|
||||||
attributes: true,
|
// is already the right theme. The video only mounts on the client, which keeps
|
||||||
attributeFilter: ["class"],
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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">
|
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
|
||||||
<source src="../media/dozzle-dark.mp4" type="video/mp4" />
|
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
|
||||||
<img src="../media/poster-dark.png" alt="" />
|
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
|
||||||
</video>
|
<video
|
||||||
<video muted loop autoplay playsinline poster="../media/poster-light.png" v-else>
|
v-if="playVideo"
|
||||||
<source src="../media/dozzle-light.mp4" type="video/mp4" />
|
:key="video"
|
||||||
<img src="../media/poster-light.png" alt="" />
|
class="absolute inset-0 size-full"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
aria-hidden="true"
|
||||||
|
:poster="poster"
|
||||||
|
>
|
||||||
|
<source :src="video" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -1,21 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="stats stats-horizontal mt-10">
|
<div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
|
||||||
<div class="stat w-40 border-0!">
|
<a
|
||||||
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div>
|
v-for="stat in stats"
|
||||||
<div class="stat-value">
|
:key="stat.label"
|
||||||
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="(value) => (value / 1e3).toFixed(1) + 'K'" />
|
class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||||
</div>
|
:href="stat.href"
|
||||||
</div>
|
>
|
||||||
|
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
|
||||||
<div class="stat">
|
<Icon :icon="stat.icon" class="size-4 opacity-70" />
|
||||||
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div>
|
{{ stat.label }}
|
||||||
<div class="stat-value">
|
</span>
|
||||||
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="(value) => (value / 1e6).toFixed(0) + 'M'" />
|
<span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
|
||||||
</div>
|
<Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
|
||||||
</div>
|
</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
import { data } from "../../activity.data";
|
import { data } from "../../activity.data";
|
||||||
import Counter from "./Counter.vue";
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,7 +8,13 @@ import "./style.css";
|
|||||||
import HeroVideo from "./components/HeroVideo.vue";
|
import HeroVideo from "./components/HeroVideo.vue";
|
||||||
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
||||||
import Stats from "./components/Stats.vue";
|
import Stats from "./components/Stats.vue";
|
||||||
|
import HeroTrust from "./components/HeroTrust.vue";
|
||||||
import Supported from "./components/Supported.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 {
|
export default {
|
||||||
...DefaultTheme,
|
...DefaultTheme,
|
||||||
@@ -16,8 +22,9 @@ export default {
|
|||||||
return h(DefaultTheme.Layout, null, {
|
return h(DefaultTheme.Layout, null, {
|
||||||
"home-hero-image": () => h(HeroVideo),
|
"home-hero-image": () => h(HeroVideo),
|
||||||
"sidebar-nav-after": () => h(BuyMeCoffee),
|
"sidebar-nav-after": () => h(BuyMeCoffee),
|
||||||
"home-hero-actions-after": () => h(Stats),
|
"home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
|
||||||
"home-hero-after": () => h(Supported),
|
"home-hero-after": () => [h(InstallCommand), h(Supported)],
|
||||||
|
"home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enhanceApp(ctx) {
|
enhanceApp(ctx) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -154,8 +154,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.VPHomeHero .text {
|
||||||
|
@apply text-3xl font-normal md:text-5xl;
|
||||||
|
}
|
||||||
|
|
||||||
.VPHomeHero .tagline {
|
.VPHomeHero .tagline {
|
||||||
@apply font-sans text-4xl font-light;
|
@apply font-sans text-lg font-light md:text-2xl;
|
||||||
color: var(--tag-line-color);
|
color: var(--tag-line-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
||||||
Counter: typeof import('./.vitepress/theme/components/Counter.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']
|
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
|
||||||
|
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
|
||||||
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
|
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
|
||||||
Supported: typeof import('./.vitepress/theme/components/Supported.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']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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
|
## <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.
|
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
|
## <Icon icon="mdi:swap-horizontal" inline /> Forward Proxy
|
||||||
|
|
||||||
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
|
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ If you need a more opinionated experience with richer provider integrations (e.g
|
|||||||
|
|
||||||
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
|
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
|
||||||
|
|
||||||
|
## Why does Dozzle keep dockerd and containerd at slightly elevated CPU when no browser is connected?
|
||||||
|
|
||||||
|
Dozzle keeps container stats streaming for up to 6 hours (2 hours on Kubernetes) after the last browser disconnects, then shuts the stats collector down on its own. This is intentional. Stats are streamed continuously so that when you reopen the UI you see historical CPU and memory history instead of a blank chart. If streaming stopped the moment you closed the tab, there would be no history to show.
|
||||||
|
|
||||||
|
The cost is a small, steady amount of CPU in dockerd and containerd, since Docker's stats API is polling based. Restarting the Dozzle container resets the timer immediately, which is why a restart drops the host back to idle. This is not configurable by design. A small timeout would break other functionality that assumes stats are still streaming, so lowering it defeats the purpose of the stats history.
|
||||||
|
|
||||||
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
|
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
|
||||||
|
|
||||||
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
|
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods", "pods/log", "nodes"]
|
resources: ["pods", "pods/log", "nodes"]
|
||||||
verbs: ["get", "list", "watch"]
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||||
|
verbs: ["get"]
|
||||||
|
- apiGroups: ["batch"]
|
||||||
|
resources: ["jobs", "cronjobs"]
|
||||||
|
verbs: ["get"]
|
||||||
- apiGroups: ["metrics.k8s.io"]
|
- apiGroups: ["metrics.k8s.io"]
|
||||||
resources: ["pods"]
|
resources: ["pods"]
|
||||||
verbs: ["get", "list"]
|
verbs: ["get", "list"]
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `list_hosts` | List all connected Docker hosts. |
|
||||||
| `get_container_stats` | Get CPU and memory usage history for a container. |
|
| `get_container_stats` | Get CPU and memory usage history for a container. |
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
# https://vitepress.dev/reference/default-theme-home-page
|
# https://vitepress.dev/reference/default-theme-home-page
|
||||||
layout: home
|
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:
|
hero:
|
||||||
name: "Dozzle"
|
name: "Dozzle"
|
||||||
|
text: "See what your containers are doing"
|
||||||
tagline: Real-time Docker logs, stats, and debugging — in your browser.
|
tagline: Real-time Docker logs, stats, and debugging — in your browser.
|
||||||
actions:
|
actions:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
@@ -22,12 +24,16 @@ features:
|
|||||||
src: /icons/document.svg
|
src: /icons/document.svg
|
||||||
width: 36
|
width: 36
|
||||||
height: 36
|
height: 36
|
||||||
|
link: /guide/what-is-dozzle#advanced-log-handling
|
||||||
|
linkText: Learn More
|
||||||
- title: Live Stats & Metrics
|
- title: Live Stats & Metrics
|
||||||
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
|
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
|
||||||
icon:
|
icon:
|
||||||
src: /icons/chart-line-data.svg
|
src: /icons/chart-line-data.svg
|
||||||
width: 36
|
width: 36
|
||||||
height: 36
|
height: 36
|
||||||
|
link: /guide/what-is-dozzle#real-time-monitoring
|
||||||
|
linkText: Learn More
|
||||||
- title: SQL Log Analysis
|
- title: SQL Log Analysis
|
||||||
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
|
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
|
||||||
icon:
|
icon:
|
||||||
@@ -52,12 +58,6 @@ features:
|
|||||||
height: 36
|
height: 36
|
||||||
link: /guide/remote-hosts
|
link: /guide/remote-hosts
|
||||||
linkText: Learn More
|
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
|
- title: Shell & Exec Access
|
||||||
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
|
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
|
||||||
icon:
|
icon:
|
||||||
@@ -66,12 +66,28 @@ features:
|
|||||||
height: 36
|
height: 36
|
||||||
link: /guide/shell
|
link: /guide/shell
|
||||||
linkText: Learn More
|
linkText: Learn More
|
||||||
- title: Officially Sponsored by Docker
|
- title: dtop in Your Terminal
|
||||||
details: Open source and part of Docker's sponsored OSS program.
|
details: A command-line companion that gives you a live view of your containers, then jumps straight into Dozzle.
|
||||||
icon:
|
icon:
|
||||||
src: /icons/docker-icon.svg
|
src: /icons/terminal-command.svg
|
||||||
width: 36
|
width: 36
|
||||||
height: 36
|
height: 36
|
||||||
link: https://hub.docker.com/r/amir20/dozzle
|
link: /guide/dtop
|
||||||
linkText: Docker Hub
|
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
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"dozzle": "workspace:*"
|
"dozzle": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8"
|
"@fontsource-variable/playfair-display": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 126 KiB |
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
Components({
|
Components({
|
||||||
dirs: [path.resolve(__dirname, ".vitepress/theme/components")],
|
dirs: [path.resolve(import.meta.dirname, ".vitepress/theme/components")],
|
||||||
extensions: ["vue", "md"],
|
extensions: ["vue", "md"],
|
||||||
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
|
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
|
||||||
dts: true,
|
dts: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
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 }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("http://dozzle-with-agent:8080/");
|
await page.goto("http://dozzle-with-agent:8080/");
|
||||||
});
|
});
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
const origin = "http://custom_base:8080";
|
||||||
|
const base = "/foobarbase";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("http://custom_base:8080/foobarbase");
|
await page.goto(origin + base);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("has right title", async ({ page }) => {
|
test("has right title", async ({ page }) => {
|
||||||
@@ -11,3 +14,35 @@ test("has right title", async ({ page }) => {
|
|||||||
test("url should have custom base", async ({ page }) => {
|
test("url should have custom base", async ({ page }) => {
|
||||||
await expect(page).toHaveURL(/foobarbase/);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -1,9 +1,59 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test("simple authentication", async ({ page }) => {
|
const BASE = "http://simple-auth:8080";
|
||||||
await page.goto("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="username"]').fill("admin");
|
||||||
await page.locator('input[name="password"]').fill("password");
|
await page.locator('input[name="password"]').fill("password");
|
||||||
await page.locator('button[type="submit"]').click();
|
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();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -13,6 +13,12 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods", "pods/log", "nodes"]
|
resources: ["pods", "pods/log", "nodes"]
|
||||||
verbs: ["get", "list", "watch"]
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||||
|
verbs: ["get"]
|
||||||
|
- apiGroups: ["batch"]
|
||||||
|
resources: ["jobs", "cronjobs"]
|
||||||
|
verbs: ["get"]
|
||||||
- apiGroups: ["metrics.k8s.io"]
|
- apiGroups: ["metrics.k8s.io"]
|
||||||
resources: ["pods"]
|
resources: ["pods"]
|
||||||
verbs: ["get", "list"]
|
verbs: ["get", "list"]
|
||||||
|
|||||||
@@ -13,52 +13,55 @@ require (
|
|||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.12.0
|
github.com/PuerkitoBio/goquery v1.12.0
|
||||||
github.com/andybalholm/brotli v1.2.2
|
github.com/andybalholm/brotli v1.2.2
|
||||||
github.com/compose-spec/compose-go/v2 v2.12.1
|
github.com/compose-spec/compose-go/v2 v2.14.0
|
||||||
github.com/expr-lang/expr v1.17.8
|
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-chi/jwtauth/v5 v5.4.0
|
||||||
github.com/go-faker/faker/v4 v4.9.0
|
github.com/go-faker/faker/v4 v4.10.1
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.2
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||||
github.com/moby/docker-image-spec v1.3.1
|
github.com/moby/docker-image-spec v1.3.1
|
||||||
github.com/moby/moby/api v1.55.0
|
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/puzpuzpuz/xsync/v4 v4.5.0
|
||||||
github.com/rs/zerolog v1.35.1
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/samber/lo v1.53.0
|
github.com/samber/lo v1.53.0
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.5
|
||||||
go.yaml.in/yaml/v3 v3.0.4
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/sync v0.21.0
|
golang.org/x/sync v0.22.0
|
||||||
google.golang.org/grpc v1.81.1
|
google.golang.org/grpc v1.83.0
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
||||||
k8s.io/api v0.36.2
|
k8s.io/api v0.36.3
|
||||||
k8s.io/apimachinery v0.36.2
|
k8s.io/apimachinery v0.36.3
|
||||||
k8s.io/client-go v0.36.2
|
k8s.io/client-go v0.36.3
|
||||||
k8s.io/metrics v0.36.2
|
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 (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 // 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/alexflint/go-scalar v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||||
github.com/bep/golibsass v1.2.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/buger/jsonparser v1.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
@@ -96,7 +99,7 @@ require (
|
|||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/gohugoio/hashstructure v0.6.0 // 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.3 // 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/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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 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/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // 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
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/term v0.44.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
|
||||||
golang.org/x/time v0.15.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
|
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/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
k8s.io/klog/v2 v2.140.0 // indirect
|
k8s.io/klog/v2 v2.140.0 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // 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
|
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
@@ -165,7 +167,7 @@ require (
|
|||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.5
|
||||||
|
|
||||||
tool (
|
tool (
|
||||||
github.com/air-verse/air
|
github.com/air-verse/air
|
||||||
|
|||||||
@@ -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/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 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
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.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
|
||||||
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/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
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 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
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 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
|
||||||
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
|
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 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
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 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 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/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 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.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 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
@@ -83,12 +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/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 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
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.13.0 h1:2+2oS3v4SrtAOBdZRAZYBsBy47D571p5EXMSCppmTtE=
|
||||||
github.com/compose-spec/compose-go/v2 v2.10.2/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
github.com/compose-spec/compose-go/v2 v2.13.0/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.14.0 h1:uaJeo5B3+OVlu+Rx2qLBcAdXPEUUzm5nQrRiGJafRAQ=
|
||||||
github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
github.com/compose-spec/compose-go/v2 v2.14.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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
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 v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
@@ -136,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/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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
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.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
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/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
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-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 h1:a4HXLwueuTCtgF93VpUsl8Zd2nG1VH2SgNWDPVEBg5U=
|
||||||
github.com/go-faker/faker/v4 v4.9.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
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 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
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 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-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 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.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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -219,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/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 h1:RnVIwSeTVDbhXbSwa+5C8QFkQVa634IdmekvSjLVMmI=
|
||||||
github.com/gohugoio/hugo v0.161.0/go.mod h1:ZJStxHMZXnnhvCfOAy6FCLbWf90zTpH/cnvWAcmoyiE=
|
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 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
|
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=
|
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
|
||||||
@@ -305,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/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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
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 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
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 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
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 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
||||||
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
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 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -357,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 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 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.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 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
@@ -428,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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
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 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
@@ -438,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/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 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
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 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
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 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
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 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
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 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.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 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
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=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -459,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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
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/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
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/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
@@ -474,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.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
@@ -486,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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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.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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
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/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -499,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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -518,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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -533,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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
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.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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -547,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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -559,19 +550,22 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
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=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
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 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
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/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
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 v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc=
|
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/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 h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
@@ -592,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=
|
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 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
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 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
|
||||||
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
|
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
|
||||||
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
|
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
|
||||||
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
|
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
|
||||||
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
|
|
||||||
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
|
|
||||||
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
|
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
|
||||||
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
|
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
|
||||||
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
|
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
|
||||||
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
|
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
|
||||||
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/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
|
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.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 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
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 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ=
|
||||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
|
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 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8=
|
||||||
k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0=
|
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/metrics v0.36.3 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ=
|
||||||
k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM=
|
||||||
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/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
|
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.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 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
|
||||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type contextKey string
|
|||||||
|
|
||||||
const remoteUser contextKey = "remoteUser"
|
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 {
|
type proxyAuthContext struct {
|
||||||
headerUser string
|
headerUser string
|
||||||
headerEmail string
|
headerEmail string
|
||||||
@@ -55,7 +61,7 @@ func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler {
|
|||||||
userRoles = ParseRole(r.Header.Get(p.headerRoles))
|
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)
|
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))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
} else {
|
} else {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
@@ -18,8 +20,13 @@ type simpleAuthContext struct {
|
|||||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
|
||||||
func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext {
|
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()
|
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.Password))
|
||||||
h.Write([]byte(user.RolesConfigured))
|
h.Write([]byte(user.RolesConfigured))
|
||||||
}
|
}
|
||||||
@@ -39,7 +46,7 @@ func (a *simpleAuthContext) CreateToken(username, password string) (string, erro
|
|||||||
return "", ErrInvalidCredentials
|
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)
|
jwtauth.SetIssuedNow(claims)
|
||||||
|
|
||||||
if a.ttl > 0 {
|
if a.ttl > 0 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -162,9 +162,7 @@ func (ls *logStreamer) startReader(parent context.Context, c container.Container
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ls.wg.Add(1)
|
ls.wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer ls.wg.Done()
|
|
||||||
defer func() {
|
defer func() {
|
||||||
ls.mu.Lock()
|
ls.mu.Lock()
|
||||||
delete(ls.readers, key)
|
delete(ls.readers, key)
|
||||||
@@ -172,7 +170,7 @@ func (ls *logStreamer) startReader(parent context.Context, c container.Container
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
ls.runReader(readerCtx, cs, minRank)
|
ls.runReader(readerCtx, cs, minRank)
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// runReader follows logs from a single container and pushes batches directly
|
// runReader follows logs from a single container and pushes batches directly
|
||||||
|
|||||||
@@ -448,14 +448,21 @@ func (s *ContainerStore) init() {
|
|||||||
|
|
||||||
case "rename":
|
case "rename":
|
||||||
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
|
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
|
||||||
if loaded {
|
if !loaded {
|
||||||
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
|
|
||||||
copy := *c
|
|
||||||
copy.Name = event.ActorAttributes["name"]
|
|
||||||
return ©, xsync.UpdateOp
|
|
||||||
} else {
|
|
||||||
return c, xsync.CancelOp
|
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 ©, xsync.UpdateOp
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
|
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")
|
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{}
|
type fakeStatsCollector struct{}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) Subscribe(_ context.Context, _ chan<- ContainerStat) {}
|
func (f *fakeStatsCollector) Subscribe(_ context.Context, _ chan<- ContainerStat) {}
|
||||||
|
|||||||
@@ -117,8 +117,7 @@ func TestEventGenerator_doesNotStallOnSustainedLevellessStream(t *testing.T) {
|
|||||||
// maxGroupTimeDelta, every line looks like an orphaned continuation, so the
|
// maxGroupTimeDelta, every line looks like an orphaned continuation, so the
|
||||||
// skip loop buffers forever and the UI shows "no logs". The generator must
|
// skip loop buffers forever and the UI shows "no logs". The generator must
|
||||||
// give up and emit instead.
|
// give up and emit instead.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx := t.Context()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
reader := &steadyLevellessReader{ctx: ctx, base: time.Now(), step: time.Millisecond, delay: time.Millisecond}
|
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
|
// startedAt far in the past so the near-start short-circuit cannot fire
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ var SupportedLogLevels map[string]struct{}
|
|||||||
var logLevels = [][]string{
|
var logLevels = [][]string{
|
||||||
{"error", "err"},
|
{"error", "err"},
|
||||||
{"warn", "warning", "wrn"},
|
{"warn", "warning", "wrn"},
|
||||||
{"info", "inf"},
|
{"info", "inf", "information"},
|
||||||
{"debug", "dbg"},
|
{"debug", "dbg"},
|
||||||
{"trace", "verbose", "ver", "vbs"},
|
{"trace", "verbose", "ver", "vbs"},
|
||||||
{"fatal", "sev", "severe", "crit", "critical"},
|
{"fatal", "sev", "severe", "crit", "critical"},
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ func TestGuessLogLevel(t *testing.T) {
|
|||||||
{"WARN: connection error: retrying", "warn"},
|
{"WARN: connection error: retrying", "warn"},
|
||||||
// Symmetric: an ERROR prefix still wins over a later info word.
|
// Symmetric: an ERROR prefix still wins over a later info word.
|
||||||
{"ERROR: handler failed, info: will retry", "error"},
|
{"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).
|
// Equal confidence between two different levels -> unknown (don't guess).
|
||||||
{"saw info: here and error: there", "unknown"},
|
{"saw info: here and error: there", "unknown"},
|
||||||
{"[INFO] [DEBUG] both bracketed", "unknown"},
|
{"[INFO] [DEBUG] both bracketed", "unknown"},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||||
)
|
)
|
||||||
@@ -36,7 +38,7 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
|||||||
} else if char == '\\' {
|
} else if char == '\\' {
|
||||||
escaping = true
|
escaping = true
|
||||||
} else if char == '"' {
|
} else if char == '"' {
|
||||||
value = log[start:i]
|
value = unescapeQuoted(log[start-1 : i+1])
|
||||||
result.Set(key, value)
|
result.Set(key, value)
|
||||||
inQuotes = false
|
inQuotes = false
|
||||||
isKey = true
|
isKey = true
|
||||||
@@ -77,3 +79,19 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
|||||||
|
|
||||||
return result, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,28 @@ func TestParseLog(t *testing.T) {
|
|||||||
),
|
),
|
||||||
wantErr: false,
|
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",
|
name: "Broken format with unexpected quotes",
|
||||||
log: `key1=value"1"= key2="value2"`,
|
log: `key1=value"1"= key2="value2"`,
|
||||||
|
|||||||
@@ -2,20 +2,46 @@ package container
|
|||||||
|
|
||||||
import "strings"
|
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
|
// PlainText renders a log event as plain text for clipboard copy, stripping
|
||||||
// ANSI escape sequences. Grouped events carry their lines in fragments and
|
// ANSI escape sequences and the internal highlight markers. Grouped events
|
||||||
// have an empty RawMessage, so they are expanded one line per fragment;
|
// carry their lines in fragments and have an empty RawMessage, so they are
|
||||||
// otherwise every grouped multi-line entry collapses to a single blank line on
|
// expanded one line per fragment; otherwise every grouped multi-line entry
|
||||||
// copy.
|
// collapses to a single blank line on copy.
|
||||||
func (e *LogEvent) PlainText() string {
|
func (e *LogEvent) PlainText() string {
|
||||||
if e.Type == LogTypeGroup {
|
if e.Type == LogTypeGroup {
|
||||||
if fragments, ok := e.Message.([]LogFragment); ok {
|
if fragments, ok := e.Message.([]LogFragment); ok {
|
||||||
lines := make([]string, len(fragments))
|
lines := make([]string, len(fragments))
|
||||||
for i, fragment := range fragments {
|
for i, fragment := range fragments {
|
||||||
lines[i] = StripANSI(fragment.Message)
|
lines[i] = StripHighlightMarkers(StripANSI(fragment.Message))
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return StripANSI(e.RawMessage)
|
return StripHighlightMarkers(StripANSI(e.RawMessage))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ func TestLogEventPlainText(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "red\nplain",
|
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 {
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,19 +99,19 @@ func (container Container) ToProto() pb.Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return pb.Container{
|
return pb.Container{
|
||||||
Id: container.ID,
|
Id: container.ID,
|
||||||
Name: container.Name,
|
Name: container.Name,
|
||||||
Image: container.Image,
|
Image: container.Image,
|
||||||
Created: timestamppb.New(container.Created),
|
Created: timestamppb.New(container.Created),
|
||||||
State: container.State,
|
State: container.State,
|
||||||
Health: container.Health,
|
Health: container.Health,
|
||||||
Host: container.Host,
|
Host: container.Host,
|
||||||
Tty: container.Tty,
|
Tty: container.Tty,
|
||||||
Labels: container.Labels,
|
Labels: container.Labels,
|
||||||
Group: container.Group,
|
Group: container.Group,
|
||||||
Started: timestamppb.New(container.StartedAt),
|
Started: timestamppb.New(container.StartedAt),
|
||||||
Finished: timestamppb.New(container.FinishedAt),
|
Finished: timestamppb.New(container.FinishedAt),
|
||||||
Stats: pbStats,
|
Stats: pbStats,
|
||||||
Command: container.Command,
|
Command: container.Command,
|
||||||
MemoryLimit: container.MemoryLimit,
|
MemoryLimit: container.MemoryLimit,
|
||||||
CpuLimit: container.CPULimit,
|
CpuLimit: container.CPULimit,
|
||||||
@@ -176,20 +176,20 @@ func FromProto(c *pb.Container) Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Container{
|
return Container{
|
||||||
ID: c.Id,
|
ID: c.Id,
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Image: c.Image,
|
Image: c.Image,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
Group: c.Group,
|
Group: c.Group,
|
||||||
Created: c.Created.AsTime(),
|
Created: c.Created.AsTime(),
|
||||||
State: c.State,
|
State: c.State,
|
||||||
Health: c.Health,
|
Health: c.Health,
|
||||||
Host: c.Host,
|
Host: c.Host,
|
||||||
Tty: c.Tty,
|
Tty: c.Tty,
|
||||||
Command: c.Command,
|
Command: c.Command,
|
||||||
StartedAt: c.Started.AsTime(),
|
StartedAt: c.Started.AsTime(),
|
||||||
FinishedAt: c.Finished.AsTime(),
|
FinishedAt: c.Finished.AsTime(),
|
||||||
Stats: utils.RingBufferFrom(300, stats),
|
Stats: utils.RingBufferFrom(300, stats),
|
||||||
MemoryLimit: c.MemoryLimit,
|
MemoryLimit: c.MemoryLimit,
|
||||||
CPULimit: c.CpuLimit,
|
CPULimit: c.CpuLimit,
|
||||||
FullyLoaded: c.FullyLoaded,
|
FullyLoaded: c.FullyLoaded,
|
||||||
@@ -233,12 +233,12 @@ func ParseContainerFilter(commaValues string) (ContainerLabels, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for val := range strings.SplitSeq(commaValues, ",") {
|
for val := range strings.SplitSeq(commaValues, ",") {
|
||||||
pos := strings.Index(val, "=")
|
before, after, ok := strings.Cut(val, "=")
|
||||||
if pos == -1 {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid filter: %s", filter)
|
return nil, fmt.Errorf("invalid filter: %s", filter)
|
||||||
}
|
}
|
||||||
key := val[:pos]
|
key := before
|
||||||
val := val[pos+1:]
|
val := after
|
||||||
filter[key] = append(filter[key], val)
|
filter[key] = append(filter[key], val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newVolumeMonitor(store *ContainerStore) *volumeMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *volumeMonitor) start(ctx context.Context) {
|
func (v *volumeMonitor) start(ctx context.Context) {
|
||||||
for i := 0; i < volumeWorkerCount; i++ {
|
for range volumeWorkerCount {
|
||||||
go v.worker(ctx)
|
go v.worker(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func NewLogReader(r io.Reader, tty bool) *LogReader {
|
|||||||
reader: bufio.NewReader(r),
|
reader: bufio.NewReader(r),
|
||||||
tty: tty,
|
tty: tty,
|
||||||
pool: &sync.Pool{
|
pool: &sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() any {
|
||||||
return bytes.NewBuffer(make([]byte, 0, 4096))
|
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) {
|
func (d *LogReader) Read() (string, container.StdType, error) {
|
||||||
message, stdType, err := d.readEvent()
|
message, stdType, err := d.readEvent()
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var std container.StdType
|
var std container.StdType
|
||||||
switch stdType {
|
switch stdType {
|
||||||
@@ -53,10 +50,18 @@ func (d *LogReader) Read() (string, container.StdType, error) {
|
|||||||
std = container.STDERR
|
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") {
|
for !strings.HasSuffix(message, "\n") {
|
||||||
tail, _, err := d.readEvent()
|
tail, _, err := d.readEvent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", std, err
|
return message, std, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, after, _ := strings.Cut(tail, " ")
|
_, after, _ := strings.Cut(tail, " ")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ package k8s
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,8 +18,14 @@ import (
|
|||||||
"github.com/amir20/dozzle/internal/container"
|
"github.com/amir20/dozzle/internal/container"
|
||||||
"github.com/amir20/dozzle/internal/utils"
|
"github.com/amir20/dozzle/internal/utils"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
|
"k8s.io/client-go/discovery"
|
||||||
|
"k8s.io/client-go/discovery/cached/memory"
|
||||||
|
"k8s.io/client-go/dynamic"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
@@ -25,15 +35,23 @@ import (
|
|||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/kubernetes/scheme"
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/restmapper"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
"k8s.io/client-go/tools/remotecommand"
|
"k8s.io/client-go/tools/remotecommand"
|
||||||
)
|
)
|
||||||
|
|
||||||
type K8sClient struct {
|
type K8sClient struct {
|
||||||
Clientset *kubernetes.Clientset
|
Clientset kubernetes.Interface
|
||||||
namespace []string
|
DynamicClient dynamic.Interface
|
||||||
config *rest.Config
|
restMapper meta.RESTMapper
|
||||||
host container.Host
|
namespace []string
|
||||||
|
config *rest.Config
|
||||||
|
host container.Host
|
||||||
|
ownerCacheMu sync.Mutex
|
||||||
|
ownerCache map[string]ownerLookupResult
|
||||||
|
|
||||||
|
mapperMu sync.Mutex
|
||||||
|
lastMapperReset time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewK8sClient(namespace []string) (*K8sClient, error) {
|
func NewK8sClient(namespace []string) (*K8sClient, error) {
|
||||||
@@ -68,6 +86,14 @@ func NewK8sClient(namespace []string) (*K8sClient, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
dynamicClient, err := dynamic.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
nodes, err := clientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
|
nodes, err := clientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,17 +105,50 @@ func NewK8sClient(namespace []string) (*K8sClient, error) {
|
|||||||
node := nodes.Items[0]
|
node := nodes.Items[0]
|
||||||
|
|
||||||
return &K8sClient{
|
return &K8sClient{
|
||||||
Clientset: clientset,
|
Clientset: clientset,
|
||||||
namespace: namespace,
|
DynamicClient: dynamicClient,
|
||||||
config: config,
|
restMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
|
||||||
|
namespace: namespace,
|
||||||
|
config: config,
|
||||||
host: container.Host{
|
host: container.Host{
|
||||||
ID: node.Status.NodeInfo.MachineID,
|
ID: node.Status.NodeInfo.MachineID,
|
||||||
Name: node.Name,
|
Name: node.Name,
|
||||||
},
|
},
|
||||||
|
ownerCache: make(map[string]ownerLookupResult),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func podToContainers(pod *corev1.Pod) []container.Container {
|
type k8sOwner struct {
|
||||||
|
APIVersion string
|
||||||
|
Kind string
|
||||||
|
Namespace string
|
||||||
|
Name string
|
||||||
|
UID string
|
||||||
|
TypeKey string
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ownerLookupResult struct {
|
||||||
|
ownerReferences []metav1.OwnerReference
|
||||||
|
found bool
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TTL bounds owner-cache staleness so entries recover after RBAC is granted
|
||||||
|
// and let expired entries be swept, keeping the cache from growing forever.
|
||||||
|
ownerCacheTTL = 5 * time.Minute
|
||||||
|
// Hard cap so a cluster with heavy ReplicaSet churn can't grow the cache unbounded.
|
||||||
|
ownerCacheMaxSize = 4096
|
||||||
|
// Evict down to this size once the cap is hit, so eviction has hysteresis and
|
||||||
|
// doesn't re-run on every subsequent insert.
|
||||||
|
ownerCacheEvictTo = ownerCacheMaxSize * 3 / 4
|
||||||
|
// Discovery resets are throttled to this interval so an unmappable owner kind
|
||||||
|
// (typo, uninstalled CRD) can't hammer the API server's discovery endpoint.
|
||||||
|
mapperResetInterval = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k *K8sClient) podToContainers(ctx context.Context, pod *corev1.Pod) []container.Container {
|
||||||
started := time.Time{}
|
started := time.Time{}
|
||||||
if pod.Status.StartTime != nil {
|
if pod.Status.StartTime != nil {
|
||||||
started = pod.Status.StartTime.Time
|
started = pod.Status.StartTime.Time
|
||||||
@@ -97,16 +156,27 @@ func podToContainers(pod *corev1.Pod) []container.Container {
|
|||||||
|
|
||||||
// Build labels map with pod labels, namespace, and owner reference
|
// Build labels map with pod labels, namespace, and owner reference
|
||||||
labels := make(map[string]string)
|
labels := make(map[string]string)
|
||||||
for k, v := range pod.Labels {
|
maps.Copy(labels, pod.Labels)
|
||||||
labels[k] = v
|
|
||||||
}
|
|
||||||
labels["namespace"] = pod.Namespace
|
labels["namespace"] = pod.Namespace
|
||||||
|
labels["@k8s.namespace"] = pod.Namespace
|
||||||
|
|
||||||
// Add owner reference if present
|
owners := k.resolveOwnerChain(ctx, pod.Namespace, pod.OwnerReferences)
|
||||||
if len(pod.OwnerReferences) > 0 {
|
if len(owners) > 0 {
|
||||||
owner := pod.OwnerReferences[0]
|
// Immediate owner kept as top-level labels for backward compatibility.
|
||||||
labels["owner.kind"] = owner.Kind
|
labels["owner.kind"] = owners[0].Kind
|
||||||
labels["owner.name"] = owner.Name
|
labels["owner.name"] = owners[0].Name
|
||||||
|
labels["owner.key"] = owners[0].Key
|
||||||
|
labels["@k8s.owner.count"] = fmt.Sprintf("%d", len(owners))
|
||||||
|
}
|
||||||
|
for i, owner := range owners {
|
||||||
|
prefix := fmt.Sprintf("@k8s.owner.%d.", i)
|
||||||
|
labels[prefix+"apiVersion"] = owner.APIVersion
|
||||||
|
labels[prefix+"kind"] = owner.Kind
|
||||||
|
labels[prefix+"namespace"] = owner.Namespace
|
||||||
|
labels[prefix+"name"] = owner.Name
|
||||||
|
labels[prefix+"uid"] = owner.UID
|
||||||
|
labels[prefix+"key"] = owner.Key
|
||||||
|
labels[ownerMembershipLabel(owner.Key)] = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
var containers []container.Container
|
var containers []container.Container
|
||||||
@@ -129,10 +199,264 @@ func podToContainers(pod *corev1.Pod) []container.Container {
|
|||||||
return containers
|
return containers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *K8sClient) resolveOwnerChain(ctx context.Context, namespace string, refs []metav1.OwnerReference) []k8sOwner {
|
||||||
|
owners := make([]k8sOwner, 0)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for len(refs) > 0 {
|
||||||
|
ref := ownerReferenceToFollow(refs)
|
||||||
|
if isNodeOwnerReference(ref) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
owner := newK8sOwner(namespace, ref)
|
||||||
|
if _, ok := seen[owner.cacheKey()]; ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen[owner.cacheKey()] = struct{}{}
|
||||||
|
owners = append(owners, owner)
|
||||||
|
|
||||||
|
next, ok := k.lookupOwnerReferences(ctx, owner)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
refs = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return owners
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerReferenceToFollow(refs []metav1.OwnerReference) metav1.OwnerReference {
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.Controller != nil && *ref.Controller {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNodeOwnerReference(ref metav1.OwnerReference) bool {
|
||||||
|
return ref.APIVersion == "v1" && ref.Kind == "Node"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newK8sOwner(namespace string, ref metav1.OwnerReference) k8sOwner {
|
||||||
|
typeKey := ownerTypeKey(ref.APIVersion, ref.Kind)
|
||||||
|
// "~" is URL-safe and not allowed in Kubernetes resource names/namespaces,
|
||||||
|
// so owner route keys stay readable without colliding with real names.
|
||||||
|
key := fmt.Sprintf("%s~%s~%s", typeKey, namespace, ref.Name)
|
||||||
|
return k8sOwner{
|
||||||
|
APIVersion: ref.APIVersion,
|
||||||
|
Kind: ref.Kind,
|
||||||
|
Namespace: namespace,
|
||||||
|
Name: ref.Name,
|
||||||
|
UID: string(ref.UID),
|
||||||
|
TypeKey: typeKey,
|
||||||
|
Key: key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o k8sOwner) cacheKey() string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/%s/%s", o.APIVersion, o.Kind, o.Namespace, o.Name, o.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerMembershipLabel(key string) string {
|
||||||
|
return "@k8s.owner.key." + base64.RawURLEncoding.EncodeToString([]byte(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerTypeKey(apiVersion, kind string) string {
|
||||||
|
if isKnownK8sOwnerType(apiVersion, kind) {
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(apiVersion, "/", "~") + "~" + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownK8sOwnerType(apiVersion, kind string) bool {
|
||||||
|
switch apiVersion + "/" + kind {
|
||||||
|
case "apps/v1/Deployment",
|
||||||
|
"apps/v1/ReplicaSet",
|
||||||
|
"apps/v1/DaemonSet",
|
||||||
|
"apps/v1/StatefulSet",
|
||||||
|
"batch/v1/Job",
|
||||||
|
"batch/v1/CronJob",
|
||||||
|
"v1/Pod",
|
||||||
|
"v1/Service",
|
||||||
|
"v1/ConfigMap",
|
||||||
|
"v1/Secret":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *K8sClient) lookupOwnerReferences(ctx context.Context, owner k8sOwner) ([]metav1.OwnerReference, bool) {
|
||||||
|
cacheKey := owner.cacheKey()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
k.ownerCacheMu.Lock()
|
||||||
|
if k.ownerCache == nil {
|
||||||
|
k.ownerCache = make(map[string]ownerLookupResult)
|
||||||
|
}
|
||||||
|
if result, ok := k.ownerCache[cacheKey]; ok && now.Before(result.expiresAt) {
|
||||||
|
k.ownerCacheMu.Unlock()
|
||||||
|
return result.ownerReferences, result.found
|
||||||
|
}
|
||||||
|
k.ownerCacheMu.Unlock()
|
||||||
|
|
||||||
|
refs, ok, cacheable := k.fetchOwnerReferences(ctx, owner)
|
||||||
|
|
||||||
|
if cacheable {
|
||||||
|
k.ownerCacheMu.Lock()
|
||||||
|
k.pruneOwnerCache(now)
|
||||||
|
k.ownerCache[cacheKey] = ownerLookupResult{ownerReferences: refs, found: ok, expiresAt: now.Add(ownerCacheTTL)}
|
||||||
|
k.ownerCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneOwnerCache drops expired entries and, only once the cache grows past
|
||||||
|
// ownerCacheMaxSize, evicts arbitrary entries down to ownerCacheEvictTo. This
|
||||||
|
// hysteresis (grow to max, drop to evictTo) bounds growth without evicting on
|
||||||
|
// every insert, and avoids clearing the map wholesale so a cluster with more
|
||||||
|
// than ownerCacheMaxSize live owners doesn't thrash. Callers must hold ownerCacheMu.
|
||||||
|
func (k *K8sClient) pruneOwnerCache(now time.Time) {
|
||||||
|
for key, result := range k.ownerCache {
|
||||||
|
if !now.Before(result.expiresAt) {
|
||||||
|
delete(k.ownerCache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(k.ownerCache) <= ownerCacheMaxSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Map iteration order is randomized, so this evicts an arbitrary subset.
|
||||||
|
for key := range k.ownerCache {
|
||||||
|
if len(k.ownerCache) <= ownerCacheEvictTo {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delete(k.ownerCache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetRESTMapper resets the cached discovery mapper so newly-registered CRDs
|
||||||
|
// can be mapped, at most once per mapperResetInterval. Returns true if it reset.
|
||||||
|
func (k *K8sClient) resetRESTMapper() bool {
|
||||||
|
resetter, ok := k.restMapper.(interface{ Reset() })
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
k.mapperMu.Lock()
|
||||||
|
defer k.mapperMu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if !k.lastMapperReset.IsZero() && now.Sub(k.lastMapperReset) < mapperResetInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
k.lastMapperReset = now
|
||||||
|
resetter.Reset()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *K8sClient) fetchOwnerReferences(ctx context.Context, owner k8sOwner) ([]metav1.OwnerReference, bool, bool) {
|
||||||
|
if k.DynamicClient == nil || k.restMapper == nil {
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
groupVersion, err := schema.ParseGroupVersion(owner.APIVersion)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to parse owner apiVersion")
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
gk := groupVersion.WithKind(owner.Kind).GroupKind()
|
||||||
|
mapping, err := k.restMapper.RESTMapping(gk, groupVersion.Version)
|
||||||
|
if err != nil {
|
||||||
|
// Discovery is cached, so a CRD registered after startup won't map until the
|
||||||
|
// cache is reset. Reset and retry, but throttle resets so a permanently
|
||||||
|
// unmappable kind can't hammer the discovery endpoint on every pod event.
|
||||||
|
if k.resetRESTMapper() {
|
||||||
|
mapping, err = k.restMapper.RESTMapping(gk, groupVersion.Version)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to map owner resource")
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resource dynamic.ResourceInterface
|
||||||
|
if mapping.Scope.Name() != meta.RESTScopeNameRoot {
|
||||||
|
resource = k.DynamicClient.Resource(mapping.Resource).Namespace(owner.Namespace)
|
||||||
|
} else {
|
||||||
|
resource = k.DynamicClient.Resource(mapping.Resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := resource.Get(ctx, owner.Name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to fetch owner resource")
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
return nil, false, apierrors.IsNotFound(err) || apierrors.IsForbidden(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.GetOwnerReferences(), true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitK8sFilters(labels container.ContainerLabels) (container.ContainerLabels, container.ContainerLabels) {
|
||||||
|
podLabels := make(container.ContainerLabels)
|
||||||
|
metadataLabels := make(container.ContainerLabels)
|
||||||
|
for key, values := range labels {
|
||||||
|
if isK8sMetadataLabel(key) || !isValidK8sLabelKey(key) {
|
||||||
|
metadataLabels[key] = values
|
||||||
|
} else {
|
||||||
|
podLabels[key] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return podLabels, metadataLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
func isK8sMetadataLabel(key string) bool {
|
||||||
|
if strings.HasPrefix(key, "@k8s.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return key == "namespace" ||
|
||||||
|
key == "owner.kind" ||
|
||||||
|
key == "owner.name" ||
|
||||||
|
key == "owner.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
k8sLabelNamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$`)
|
||||||
|
k8sLabelPrefixPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func isValidK8sLabelKey(key string) bool {
|
||||||
|
prefix, name, hasPrefix := strings.Cut(key, "/")
|
||||||
|
if !hasPrefix {
|
||||||
|
name = prefix
|
||||||
|
} else if len(prefix) == 0 || len(prefix) > 253 || !k8sLabelPrefixPattern.MatchString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(name) <= 63 && k8sLabelNamePattern.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesContainerLabels(labels map[string]string, filters container.ContainerLabels) bool {
|
||||||
|
for key, values := range filters {
|
||||||
|
value, ok := labels[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
matched := slices.Contains(values, value)
|
||||||
|
if !matched {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (k *K8sClient) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) {
|
func (k *K8sClient) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) {
|
||||||
|
podLabels, metadataLabels := splitK8sFilters(labels)
|
||||||
selector := ""
|
selector := ""
|
||||||
if labels.Exists() {
|
if podLabels.Exists() {
|
||||||
for key, values := range labels {
|
for key, values := range podLabels {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if selector != "" {
|
if selector != "" {
|
||||||
selector += ","
|
selector += ","
|
||||||
@@ -149,7 +473,12 @@ func (k *K8sClient) ListContainers(ctx context.Context, labels container.Contain
|
|||||||
}
|
}
|
||||||
var containers []container.Container
|
var containers []container.Container
|
||||||
for _, pod := range pods.Items {
|
for _, pod := range pods.Items {
|
||||||
containers = append(containers, podToContainers(&pod)...)
|
for _, c := range k.podToContainers(ctx, &pod) {
|
||||||
|
if metadataLabels.Exists() && !matchesContainerLabels(c.Labels, metadataLabels) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
containers = append(containers, c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lo.T2[[]container.Container, error](containers, nil)
|
return lo.T2[[]container.Container, error](containers, nil)
|
||||||
})
|
})
|
||||||
@@ -201,7 +530,7 @@ func (k *K8sClient) FindContainer(ctx context.Context, id string) (container.Con
|
|||||||
return container.Container{}, err
|
return container.Container{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range podToContainers(pod) {
|
for _, c := range k.podToContainers(ctx, pod) {
|
||||||
if c.ID == id {
|
if c.ID == id {
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@@ -274,7 +603,7 @@ func (k *K8sClient) ContainerEvents(ctx context.Context, ch chan<- container.Con
|
|||||||
name = "update"
|
name = "update"
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range podToContainers(pod) {
|
for _, c := range k.podToContainers(ctx, pod) {
|
||||||
ch <- container.ContainerEvent{
|
ch <- container.ContainerEvent{
|
||||||
Name: name,
|
Name: name,
|
||||||
ActorID: c.ID,
|
ActorID: c.ID,
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/container"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||||
|
k8sfake "k8s.io/client-go/kubernetes/fake"
|
||||||
|
k8stesting "k8s.io/client-go/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPodToContainersAddsOwnerChainLabels(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t,
|
||||||
|
&appsv1.ReplicaSet{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "api-6f88b977f4",
|
||||||
|
UID: types.UID("rs-uid"),
|
||||||
|
OwnerReferences: []metav1.OwnerReference{
|
||||||
|
{APIVersion: "apps/v1", Kind: "Deployment", Name: "api", UID: types.UID("deploy-uid")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&appsv1.Deployment{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "api", UID: types.UID("deploy-uid")},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
containers := client.podToContainers(t.Context(), podWithOwner())
|
||||||
|
require.Len(t, containers, 1)
|
||||||
|
|
||||||
|
labels := containers[0].Labels
|
||||||
|
assert.Equal(t, "default", labels["namespace"])
|
||||||
|
assert.Equal(t, "default", labels["@k8s.namespace"])
|
||||||
|
assert.Equal(t, "ReplicaSet", labels["owner.kind"])
|
||||||
|
assert.Equal(t, "api-6f88b977f4", labels["owner.name"])
|
||||||
|
assert.Equal(t, "ReplicaSet~default~api-6f88b977f4", labels["owner.key"])
|
||||||
|
assert.Equal(t, "2", labels["@k8s.owner.count"])
|
||||||
|
|
||||||
|
assert.Equal(t, "ReplicaSet", labels["@k8s.owner.0.kind"])
|
||||||
|
assert.Equal(t, "api-6f88b977f4", labels["@k8s.owner.0.name"])
|
||||||
|
assert.Equal(t, "ReplicaSet~default~api-6f88b977f4", labels["@k8s.owner.0.key"])
|
||||||
|
assert.Equal(t, "Deployment", labels["@k8s.owner.1.kind"])
|
||||||
|
assert.Equal(t, "api", labels["@k8s.owner.1.name"])
|
||||||
|
assert.Equal(t, "Deployment~default~api", labels["@k8s.owner.1.key"])
|
||||||
|
// Legacy duplicated k8s.owner.* labels are no longer emitted.
|
||||||
|
assert.Empty(t, labels["k8s.owner.count"])
|
||||||
|
assert.Empty(t, labels["k8s.owner.0.kind"])
|
||||||
|
assert.Equal(t, "true", labels[ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4")])
|
||||||
|
assert.Equal(t, "true", labels[ownerMembershipLabel("Deployment~default~api")])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPodToContainersStopsOwnerChainWhenOwnerCannotBeFetched(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t)
|
||||||
|
|
||||||
|
containers := client.podToContainers(t.Context(), podWithOwner())
|
||||||
|
require.Len(t, containers, 1)
|
||||||
|
|
||||||
|
labels := containers[0].Labels
|
||||||
|
assert.Equal(t, "1", labels["@k8s.owner.count"])
|
||||||
|
assert.Equal(t, "ReplicaSet", labels["@k8s.owner.0.kind"])
|
||||||
|
assert.Equal(t, "api-6f88b977f4", labels["@k8s.owner.0.name"])
|
||||||
|
assert.Empty(t, labels["@k8s.owner.1.kind"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPodToContainersDoesNotAddNodeOwner(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t)
|
||||||
|
pod := podWithOwner()
|
||||||
|
pod.OwnerReferences = []metav1.OwnerReference{
|
||||||
|
{APIVersion: "v1", Kind: "Node", Name: "node-1", UID: types.UID("node-uid")},
|
||||||
|
}
|
||||||
|
|
||||||
|
containers := client.podToContainers(t.Context(), pod)
|
||||||
|
require.Len(t, containers, 1)
|
||||||
|
|
||||||
|
labels := containers[0].Labels
|
||||||
|
assert.Empty(t, labels["owner.kind"])
|
||||||
|
assert.Empty(t, labels["@k8s.owner.count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPodToContainersStopsBeforeNodeOwnerInChain(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t,
|
||||||
|
&appsv1.ReplicaSet{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "api-6f88b977f4",
|
||||||
|
UID: types.UID("rs-uid"),
|
||||||
|
OwnerReferences: []metav1.OwnerReference{
|
||||||
|
{APIVersion: "v1", Kind: "Node", Name: "node-1", UID: types.UID("node-uid")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
containers := client.podToContainers(t.Context(), podWithOwner())
|
||||||
|
require.Len(t, containers, 1)
|
||||||
|
|
||||||
|
labels := containers[0].Labels
|
||||||
|
assert.Equal(t, "1", labels["@k8s.owner.count"])
|
||||||
|
assert.Equal(t, "ReplicaSet", labels["@k8s.owner.0.kind"])
|
||||||
|
assert.Empty(t, labels["@k8s.owner.1.kind"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListContainersAppliesSyntheticOwnerFiltersAfterPodList(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t,
|
||||||
|
&appsv1.ReplicaSet{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "api-6f88b977f4",
|
||||||
|
UID: types.UID("rs-uid"),
|
||||||
|
OwnerReferences: []metav1.OwnerReference{
|
||||||
|
{APIVersion: "apps/v1", Kind: "Deployment", Name: "api", UID: types.UID("deploy-uid")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&appsv1.Deployment{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "api", UID: types.UID("deploy-uid")},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
client.Clientset = k8sfake.NewSimpleClientset(podWithOwner())
|
||||||
|
|
||||||
|
containers, err := client.ListContainers(t.Context(), container.ContainerLabels{
|
||||||
|
"app": {"api"},
|
||||||
|
ownerMembershipLabel("Deployment~default~api"): {"true"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, containers, 1)
|
||||||
|
assert.Equal(t, "default:api-6f88b977f4-pod:api", containers[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitK8sFiltersKeepsInvalidSyntheticKeysOutOfPodSelector(t *testing.T) {
|
||||||
|
podLabels, metadataLabels := splitK8sFilters(container.ContainerLabels{
|
||||||
|
"app": {"api"},
|
||||||
|
"team.example.com/component": {"backend"},
|
||||||
|
"@k8s.namespace": {"default"},
|
||||||
|
"@k8s.owner.key.abc123": {"true"},
|
||||||
|
"not:a:kubernetes:label:key": {"value"},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, container.ContainerLabels{
|
||||||
|
"app": {"api"},
|
||||||
|
"team.example.com/component": {"backend"},
|
||||||
|
}, podLabels)
|
||||||
|
assert.Equal(t, container.ContainerLabels{
|
||||||
|
"@k8s.namespace": {"default"},
|
||||||
|
"@k8s.owner.key.abc123": {"true"},
|
||||||
|
"not:a:kubernetes:label:key": {"value"},
|
||||||
|
}, metadataLabels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerTypeKeyUsesFullAPINameForUnknownTypes(t *testing.T) {
|
||||||
|
assert.Equal(t, "Deployment", ownerTypeKey("apps/v1", "Deployment"))
|
||||||
|
assert.Equal(t, "argoproj.io~v1alpha1~Rollout", ownerTypeKey("argoproj.io/v1alpha1", "Rollout"))
|
||||||
|
|
||||||
|
ref := metav1.OwnerReference{APIVersion: "argoproj.io/v1alpha1", Kind: "Rollout", Name: "api"}
|
||||||
|
assert.Equal(t, "argoproj.io~v1alpha1~Rollout~default~api", newK8sOwner("default", ref).Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerReferenceToFollowPrefersController(t *testing.T) {
|
||||||
|
controller := true
|
||||||
|
|
||||||
|
ref := ownerReferenceToFollow([]metav1.OwnerReference{
|
||||||
|
{Kind: "ConfigMap", Name: "sidecar-config"},
|
||||||
|
{Kind: "ReplicaSet", Name: "api-6f88b977f4", Controller: &controller},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "ReplicaSet", ref.Kind)
|
||||||
|
assert.Equal(t, "api-6f88b977f4", ref.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupOwnerReferencesDoesNotCacheTransientFailures(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t)
|
||||||
|
dynamicClient := client.DynamicClient.(*dynamicfake.FakeDynamicClient)
|
||||||
|
calls := 0
|
||||||
|
dynamicClient.PrependReactor("get", "replicasets", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
||||||
|
calls++
|
||||||
|
return true, nil, context.Canceled
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, ok := client.lookupOwnerReferences(ctx, replicaSetOwner())
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
_, ok = client.lookupOwnerReferences(ctx, replicaSetOwner())
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, 2, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupOwnerReferencesCachesRealNegatives(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "forbidden",
|
||||||
|
err: apierrors.NewForbidden(schema.GroupResource{Group: "apps", Resource: "replicasets"}, "api-6f88b977f4", errors.New("denied")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
err: apierrors.NewNotFound(schema.GroupResource{Group: "apps", Resource: "replicasets"}, "api-6f88b977f4"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t)
|
||||||
|
dynamicClient := client.DynamicClient.(*dynamicfake.FakeDynamicClient)
|
||||||
|
calls := 0
|
||||||
|
dynamicClient.PrependReactor("get", "replicasets", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
||||||
|
calls++
|
||||||
|
return true, nil, tt.err
|
||||||
|
})
|
||||||
|
|
||||||
|
_, ok := client.lookupOwnerReferences(t.Context(), replicaSetOwner())
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
_, ok = client.lookupOwnerReferences(t.Context(), replicaSetOwner())
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupOwnerReferencesRefetchesAfterTTL(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t,
|
||||||
|
&appsv1.ReplicaSet{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "ReplicaSet"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "api-6f88b977f4", UID: types.UID("rs-uid")},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dynamicClient := client.DynamicClient.(*dynamicfake.FakeDynamicClient)
|
||||||
|
calls := 0
|
||||||
|
dynamicClient.PrependReactor("get", "replicasets", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
||||||
|
calls++
|
||||||
|
return false, nil, nil
|
||||||
|
})
|
||||||
|
owner := replicaSetOwner()
|
||||||
|
|
||||||
|
_, ok := client.lookupOwnerReferences(t.Context(), owner)
|
||||||
|
assert.True(t, ok)
|
||||||
|
client.lookupOwnerReferences(t.Context(), owner)
|
||||||
|
assert.Equal(t, 1, calls, "second lookup should be served from cache")
|
||||||
|
|
||||||
|
// Expire the cached entry and confirm the next lookup refetches.
|
||||||
|
client.ownerCacheMu.Lock()
|
||||||
|
entry := client.ownerCache[owner.cacheKey()]
|
||||||
|
entry.expiresAt = time.Now().Add(-time.Minute)
|
||||||
|
client.ownerCache[owner.cacheKey()] = entry
|
||||||
|
client.ownerCacheMu.Unlock()
|
||||||
|
|
||||||
|
client.lookupOwnerReferences(t.Context(), owner)
|
||||||
|
assert.Equal(t, 2, calls, "expired entry should trigger a refetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneOwnerCacheEvictsGracefully(t *testing.T) {
|
||||||
|
client := newTestK8sClient(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
client.ownerCacheMu.Lock()
|
||||||
|
for i := range 10 {
|
||||||
|
client.ownerCache[fmt.Sprintf("expired-%d", i)] = ownerLookupResult{expiresAt: now.Add(-time.Minute)}
|
||||||
|
}
|
||||||
|
for i := range ownerCacheMaxSize + 100 {
|
||||||
|
client.ownerCache[fmt.Sprintf("fresh-%d", i)] = ownerLookupResult{expiresAt: now.Add(time.Minute)}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.pruneOwnerCache(now)
|
||||||
|
|
||||||
|
assert.LessOrEqual(t, len(client.ownerCache), ownerCacheEvictTo)
|
||||||
|
assert.NotEmpty(t, client.ownerCache, "should evict down, not clear wholesale")
|
||||||
|
for i := range 10 {
|
||||||
|
_, ok := client.ownerCache[fmt.Sprintf("expired-%d", i)]
|
||||||
|
assert.False(t, ok, "expired entries should be dropped first")
|
||||||
|
}
|
||||||
|
client.ownerCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type resettableMapper struct {
|
||||||
|
meta.RESTMapper
|
||||||
|
resets int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *resettableMapper) Reset() { m.resets++ }
|
||||||
|
|
||||||
|
func TestResetRESTMapperThrottles(t *testing.T) {
|
||||||
|
mapper := &resettableMapper{}
|
||||||
|
client := &K8sClient{restMapper: mapper}
|
||||||
|
|
||||||
|
assert.True(t, client.resetRESTMapper(), "first reset should proceed")
|
||||||
|
assert.False(t, client.resetRESTMapper(), "immediate second reset should be throttled")
|
||||||
|
assert.Equal(t, 1, mapper.resets)
|
||||||
|
|
||||||
|
// Simulate the throttle interval elapsing.
|
||||||
|
client.mapperMu.Lock()
|
||||||
|
client.lastMapperReset = time.Now().Add(-2 * mapperResetInterval)
|
||||||
|
client.mapperMu.Unlock()
|
||||||
|
|
||||||
|
assert.True(t, client.resetRESTMapper(), "reset should proceed after the interval")
|
||||||
|
assert.Equal(t, 2, mapper.resets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func podWithOwner() *corev1.Pod {
|
||||||
|
return &corev1.Pod{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "api-6f88b977f4-pod",
|
||||||
|
Labels: map[string]string{"app": "api"},
|
||||||
|
OwnerReferences: []metav1.OwnerReference{
|
||||||
|
{APIVersion: "apps/v1", Kind: "ReplicaSet", Name: "api-6f88b977f4", UID: types.UID("rs-uid")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
NodeName: "node-1",
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{Name: "api", Image: "example/api:latest"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replicaSetOwner() k8sOwner {
|
||||||
|
return newK8sOwner("default", metav1.OwnerReference{
|
||||||
|
APIVersion: "apps/v1",
|
||||||
|
Kind: "ReplicaSet",
|
||||||
|
Name: "api-6f88b977f4",
|
||||||
|
UID: types.UID("rs-uid"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestK8sClient(t *testing.T, objects ...runtime.Object) *K8sClient {
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
require.NoError(t, corev1.AddToScheme(scheme))
|
||||||
|
require.NoError(t, appsv1.AddToScheme(scheme))
|
||||||
|
|
||||||
|
mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{appsv1.SchemeGroupVersion, corev1.SchemeGroupVersion})
|
||||||
|
mapper.Add(appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), meta.RESTScopeNamespace)
|
||||||
|
mapper.Add(appsv1.SchemeGroupVersion.WithKind("Deployment"), meta.RESTScopeNamespace)
|
||||||
|
|
||||||
|
return &K8sClient{
|
||||||
|
Clientset: k8sfake.NewSimpleClientset(),
|
||||||
|
DynamicClient: dynamicfake.NewSimpleDynamicClient(scheme, objects...),
|
||||||
|
restMapper: mapper,
|
||||||
|
namespace: []string{"default"},
|
||||||
|
ownerCache: make(map[string]ownerLookupResult),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,9 @@ func NewLogReader(reader io.ReadCloser) *LogReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogReader) Read() (string, container.StdType, error) {
|
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')
|
line, err := r.reader.ReadString('\n')
|
||||||
if err != nil {
|
return line, container.STDOUT, err
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return line, container.STDOUT, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
"github.com/amir20/dozzle/internal/container"
|
"github.com/amir20/dozzle/internal/container"
|
||||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -48,6 +49,19 @@ func NewServer(hostService HostService, labels container.ContainerLabels, versio
|
|||||||
return s
|
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.
|
// Handler returns an http.Handler for the MCP streamable HTTP transport.
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
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."`
|
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 {
|
type getContainerStatsParams struct {
|
||||||
Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."`
|
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."`
|
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},
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
||||||
}, s.handleGetContainerLogs)
|
}, 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{
|
mcp.AddTool(s.mcpServer, &mcp.Tool{
|
||||||
Name: "list_hosts",
|
Name: "list_hosts",
|
||||||
Description: "List all Docker hosts connected to Dozzle.",
|
Description: "List all Docker hosts connected to Dozzle.",
|
||||||
@@ -102,7 +131,7 @@ func (s *Server) registerTools() {
|
|||||||
// --- Tool Handlers ---
|
// --- Tool Handlers ---
|
||||||
|
|
||||||
func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolRequest, params *listContainersParams) (*mcp.CallToolResult, any, error) {
|
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 {
|
for _, err := range errs {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("partial failure listing containers from a host")
|
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
|
}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) {
|
const maxLogSize = 1024 * 1024 // 1MB limit
|
||||||
if params.Host == "" || params.ContainerID == "" {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "host and container_id are required"}},
|
|
||||||
IsError: true,
|
|
||||||
}, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels)
|
// mcpLogEntry is the JSON shape returned by the log tools.
|
||||||
if err != nil {
|
type mcpLogEntry struct {
|
||||||
return &mcp.CallToolResult{
|
Timestamp string `json:"timestamp"`
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
|
Level string `json:"level,omitempty"`
|
||||||
IsError: true,
|
Stream string `json:"stream,omitempty"`
|
||||||
}, nil, nil
|
Type string `json:"type"`
|
||||||
}
|
Message any `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
stream := ""
|
func errorResult(text string) *mcp.CallToolResult {
|
||||||
if params.Stream != nil {
|
return &mcp.CallToolResult{
|
||||||
stream = *params.Stream
|
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":
|
case "", "all":
|
||||||
stdType = container.STDALL
|
return container.STDALL, nil
|
||||||
case "stdout":
|
case "stdout":
|
||||||
stdType = container.STDOUT
|
return container.STDOUT, nil
|
||||||
case "stderr":
|
case "stderr":
|
||||||
stdType = container.STDERR
|
return container.STDERR, nil
|
||||||
default:
|
default:
|
||||||
return &mcp.CallToolResult{
|
return container.STDALL, errorResult(fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", value))
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", stream)}},
|
}
|
||||||
IsError: true,
|
}
|
||||||
}, nil, nil
|
|
||||||
|
// 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
|
stdType, errResult := parseStream(stream)
|
||||||
if params.SinceMinutes != nil && *params.SinceMinutes > 0 {
|
if errResult != nil {
|
||||||
sinceMinutes = *params.SinceMinutes
|
return nil, nil, errResult
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := 5
|
||||||
|
if sinceMinutes != nil && *sinceMinutes > 0 {
|
||||||
|
minutes = *sinceMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
logCtx, cancel := context.WithCancel(ctx)
|
logCtx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
since := time.Now().Add(-time.Duration(minutes) * time.Minute)
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(sinceMinutes) * time.Minute)
|
|
||||||
events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType)
|
events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &mcp.CallToolResult{
|
cancel()
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("failed to read logs: %v", err)}},
|
return nil, nil, errorResult(fmt.Sprintf("failed to read logs: %v", err))
|
||||||
IsError: true,
|
|
||||||
}, nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type logEntry struct {
|
return events, cancel, nil
|
||||||
Timestamp string `json:"timestamp"`
|
}
|
||||||
Level string `json:"level,omitempty"`
|
|
||||||
Stream string `json:"stream,omitempty"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message any `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []logEntry
|
// eventMessage extracts the JSON-encodable message payload for a log event.
|
||||||
totalSize := 0
|
func eventMessage(event *container.LogEvent) any {
|
||||||
const maxSize = 1024 * 1024 // 1MB limit
|
switch event.Type {
|
||||||
|
case container.LogTypeGroup:
|
||||||
for event := range events {
|
if fragments, ok := event.Message.([]container.LogFragment); ok {
|
||||||
var msg any
|
lines := make([]string, len(fragments))
|
||||||
switch event.Type {
|
for i, f := range fragments {
|
||||||
case container.LogTypeGroup:
|
lines[i] = f.Message
|
||||||
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
|
|
||||||
}
|
}
|
||||||
case container.LogTypeComplex:
|
return lines
|
||||||
msg = event.Message
|
|
||||||
default:
|
|
||||||
msg = event.RawMessage
|
|
||||||
}
|
}
|
||||||
|
return event.RawMessage
|
||||||
|
case container.LogTypeComplex:
|
||||||
|
return event.Message
|
||||||
|
default:
|
||||||
|
return event.RawMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry := logEntry{
|
func newLogEntry(event *container.LogEvent) mcpLogEntry {
|
||||||
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
|
return mcpLogEntry{
|
||||||
Level: event.Level,
|
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
|
||||||
Stream: event.Stream,
|
Level: event.Level,
|
||||||
Type: string(event.Type),
|
Stream: event.Stream,
|
||||||
Message: msg,
|
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)
|
line, err := json.Marshal(entry)
|
||||||
@@ -251,30 +304,105 @@ func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalSize += len(line) + 1
|
totalSize += len(line) + 1
|
||||||
if totalSize > maxSize {
|
if totalSize > maxLogSize {
|
||||||
|
truncated = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
|
return entries, scanned, truncated
|
||||||
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
// encodeLogEntries renders entries as newline-delimited JSON.
|
||||||
return &mcp.CallToolResult{
|
func encodeLogEntries(entries []mcpLogEntry) (string, error) {
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "(no logs in the specified time range)"}},
|
|
||||||
}, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
encoder := json.NewEncoder(&sb)
|
encoder := json.NewEncoder(&sb)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if err := encoder.Encode(entry); err != nil {
|
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{
|
func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) {
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: strings.TrimRight(sb.String(), "\n")}},
|
if params.Host == "" || params.ContainerID == "" {
|
||||||
}, nil, nil
|
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) {
|
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
|
}, 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 {
|
if err != nil {
|
||||||
return &mcp.CallToolResult{
|
return &mcp.CallToolResult{
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
|
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
"github.com/amir20/dozzle/internal/container"
|
"github.com/amir20/dozzle/internal/container"
|
||||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||||
"github.com/amir20/dozzle/internal/utils"
|
"github.com/amir20/dozzle/internal/utils"
|
||||||
@@ -22,6 +24,10 @@ type mockHostService struct {
|
|||||||
listErrs []error
|
listErrs []error
|
||||||
logEvents []*container.LogEvent
|
logEvents []*container.LogEvent
|
||||||
logErr error
|
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 {
|
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) {
|
func (m *mockHostService) FindContainer(host string, id string, labels container.ContainerLabels) (*container_support.ContainerService, error) {
|
||||||
|
m.gotLabels = labels
|
||||||
if m.findErr != nil {
|
if m.findErr != nil {
|
||||||
return nil, m.findErr
|
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) {
|
func (m *mockHostService) ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error) {
|
||||||
|
m.gotLabels = labels
|
||||||
return m.containers, m.listErrs
|
return m.containers, m.listErrs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +81,35 @@ func (m *mockHostService) Hosts() []container.Host {
|
|||||||
return m.hosts
|
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) {
|
func TestListContainers(t *testing.T) {
|
||||||
svc := &mockHostService{
|
svc := &mockHostService{
|
||||||
containers: []container.Container{
|
containers: []container.Container{
|
||||||
@@ -292,9 +329,10 @@ func TestNewServerRegistersTools(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, toolNames, "list_containers")
|
assert.Contains(t, toolNames, "list_containers")
|
||||||
assert.Contains(t, toolNames, "get_container_logs")
|
assert.Contains(t, toolNames, "get_container_logs")
|
||||||
|
assert.Contains(t, toolNames, "search_container_logs")
|
||||||
assert.Contains(t, toolNames, "list_hosts")
|
assert.Contains(t, toolNames, "list_hosts")
|
||||||
assert.Contains(t, toolNames, "get_container_stats")
|
assert.Contains(t, toolNames, "get_container_stats")
|
||||||
assert.Len(t, tools.Tools, 4)
|
assert.Len(t, tools.Tools, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetContainerLogs(t *testing.T) {
|
func TestGetContainerLogs(t *testing.T) {
|
||||||
@@ -362,3 +400,175 @@ func TestGetContainerLogsInvalidStream(t *testing.T) {
|
|||||||
text := result.Content[0].(*mcp.TextContent).Text
|
text := result.Content[0].(*mcp.TextContent).Text
|
||||||
assert.Contains(t, text, "invalid stream")
|
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"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"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)}
|
var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}
|
||||||
|
|
||||||
func isBlockedIP(ip net.IP) bool {
|
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() ||
|
if ip.IsLoopback() ||
|
||||||
ip.IsLinkLocalUnicast() ||
|
ip.IsLinkLocalUnicast() ||
|
||||||
ip.IsLinkLocalMulticast() ||
|
ip.IsLinkLocalMulticast() ||
|
||||||
@@ -50,6 +61,59 @@ func isBlockedIP(ip net.IP) bool {
|
|||||||
return false
|
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) {
|
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -226,9 +226,21 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
"fe80::1",
|
"fe80::1",
|
||||||
"224.0.0.1",
|
"224.0.0.1",
|
||||||
"0.0.0.0",
|
"0.0.0.0",
|
||||||
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
|
"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.255.255.255", // top of 0.0.0.0/8
|
||||||
"255.255.255.255", // limited broadcast
|
"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 {
|
for _, s := range blocked {
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
@@ -242,6 +254,9 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
"172.16.5.10",
|
"172.16.5.10",
|
||||||
"8.8.8.8",
|
"8.8.8.8",
|
||||||
"2606:4700:4700::1111",
|
"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 {
|
for _, s := range allowed {
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
|
|||||||