Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f28474d46 | |||
| a25c379771 | |||
| 7aa449d77c | |||
| e1e2959856 | |||
| 8e39e35127 | |||
| 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 | |||
| beddc79892 | |||
| f7557ccbb5 | |||
| 6391853f3a | |||
| 619a844feb | |||
| e989caed33 | |||
| ad2b124a9e | |||
| 19c01e0fb4 | |||
| b38117bfae | |||
| 134f812336 | |||
| 55ab21b1bf | |||
| 853f46deb4 | |||
| ff0f855cf6 | |||
| 38b8511042 | |||
| 3c68345938 | |||
| acaec204b6 | |||
| 9b9c04498c | |||
| f13e169d10 | |||
| 2b23684dbd |
@@ -3,5 +3,11 @@
|
||||
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump",
|
||||
"minimumReleaseAge": "24 hours",
|
||||
"ignoreDeps": []
|
||||
"ignoreDeps": [],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||
"groupName": "playwright"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -12,16 +12,19 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
@@ -31,12 +34,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.4
|
||||
go-version: 1.26.5
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
@@ -50,16 +53,19 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
@@ -93,20 +99,20 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -124,9 +130,10 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
push: true
|
||||
sbom: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -142,13 +149,11 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,20 +18,20 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -24,14 +24,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
@@ -15,18 +15,17 @@ jobs:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -37,14 +36,17 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -56,12 +58,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.4"
|
||||
go-version: "1.26.5"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
@@ -77,11 +79,11 @@ jobs:
|
||||
name: Go Staticcheck
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.4"
|
||||
go-version: "1.26.5"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
@@ -94,18 +96,17 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -21,6 +21,10 @@ fake_assets:
|
||||
test: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 40s ./...
|
||||
|
||||
.PHONY: test-update
|
||||
test-update: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 5s ./... -- -- -u
|
||||
|
||||
.PHONY: build
|
||||
build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
/* oxlint-disable */
|
||||
/* oxfmt-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
@@ -17,6 +21,7 @@ declare global {
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const colorize: typeof import('./utils/index').colorize
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
@@ -66,8 +71,10 @@ declare global {
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const getDeep: typeof import('./utils/index').getDeep
|
||||
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
|
||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||
@@ -117,6 +124,7 @@ declare global {
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
@@ -213,6 +221,7 @@ declare global {
|
||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useCommands: typeof import('./composable/commands').useCommands
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||
@@ -244,6 +253,7 @@ declare global {
|
||||
const useElementBounding: typeof import('@vueuse/core').useElementBounding
|
||||
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
|
||||
const useElementHover: typeof import('@vueuse/core').useElementHover
|
||||
const useElementOverflow: typeof import('@vueuse/core').useElementOverflow
|
||||
const useElementSize: typeof import('@vueuse/core').useElementSize
|
||||
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
|
||||
const useEventBus: typeof import('@vueuse/core').useEventBus
|
||||
@@ -417,6 +427,9 @@ declare global {
|
||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||
import('./composable/cloudLogSearch')
|
||||
// @ts-ignore
|
||||
export type { CommandSection, Command } from './composable/commands'
|
||||
import('./composable/commands')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
@@ -435,7 +448,7 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
@@ -458,6 +471,7 @@ declare module 'vue' {
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
||||
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
|
||||
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
@@ -506,8 +520,10 @@ declare module 'vue' {
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -556,6 +572,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
@@ -649,6 +666,7 @@ declare module 'vue' {
|
||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useCommands: UnwrapRef<typeof import('./composable/commands')['useCommands']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
@@ -680,6 +698,7 @@ declare module 'vue' {
|
||||
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
|
||||
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
|
||||
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
|
||||
readonly useElementOverflow: UnwrapRef<typeof import('@vueuse/core')['useElementOverflow']>
|
||||
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
|
||||
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
|
||||
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
|
||||
|
||||
@@ -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:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowCollapse': typeof import('~icons/mdi/arrow-collapse')['default']
|
||||
'Mdi:arrowExpand': typeof import('~icons/mdi/arrow-expand')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
||||
@@ -169,6 +171,7 @@ declare module 'vue' {
|
||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||
'Ph:database': typeof import('~icons/ph/database')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:downloadSimple': typeof import('~icons/ph/download-simple')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
<div v-if="container.customGroup" class="text-base-content/50 truncate text-xs">
|
||||
{{ container.customGroup }}
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
|
||||
<td v-if="isVisible('state')">{{ container.state }}</td>
|
||||
@@ -151,7 +154,8 @@ const fields: Record<
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
mobileLabel: "label.name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.name.localeCompare(b.name) || (a.customGroup ?? "").localeCompare(b.customGroup ?? "")) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
host: {
|
||||
|
||||
@@ -199,7 +199,7 @@ const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
||||
|
||||
const router = useRouter();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { t } = useI18n();
|
||||
const { showToast, removeToast } = useToast();
|
||||
|
||||
@@ -366,10 +366,25 @@ a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
/* daisyUI's .menu is width: fit-content, so nested submenus (Streams, Levels)
|
||||
* shrink to their content and the hover highlight stops short. Stretch them to
|
||||
* fill the dropdown so the row highlight spans the full width. */
|
||||
.menu li ul {
|
||||
margin-inline-start: 0;
|
||||
width: 100%;
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the solid level colors, but use white labels in the light theme so the
|
||||
* text reads against the saturated chip backgrounds. warn is a light orange,
|
||||
* where dark text has better contrast than white, so it keeps the default. */
|
||||
[data-theme="light"] .badge[data-level="info"],
|
||||
[data-theme="light"] .badge[data-level="debug"],
|
||||
[data-theme="light"] .badge[data-level="trace"],
|
||||
[data-theme="light"] .badge[data-level="error"],
|
||||
[data-theme="light"] .badge[data-level="fatal"] {
|
||||
color: oklch(100% 0 0) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
></div>
|
||||
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div v-else>{{ other.name }}</div>
|
||||
<div v-if="other.hostLabel !== container.hostLabel" class="text-base-content/50 text-xs">
|
||||
{{ other.hostLabel }}
|
||||
</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
@@ -46,8 +49,20 @@
|
||||
</div>
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<VolumeWarning :container="container" />
|
||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
<Tag
|
||||
class="group hidden! cursor-pointer items-center gap-1.5 pr-1! font-mono @md:inline-flex!"
|
||||
size="small"
|
||||
role="button"
|
||||
:title="$t('toolbar.copy-image')"
|
||||
:aria-label="$t('toolbar.copy-image')"
|
||||
@click="copyImage"
|
||||
>
|
||||
<span class="truncate">{{ imageTag }}</span>
|
||||
<span
|
||||
class="bg-base-content/10 text-base-content/40 group-hover:text-base-content/70 flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<mdi:content-copy class="size-3" />
|
||||
</span>
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,6 +71,21 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { showToast } = useToast();
|
||||
|
||||
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
|
||||
|
||||
async function copyImage() {
|
||||
if (!isSupported.value) return;
|
||||
await copy(imageTag.value);
|
||||
if (copied.value) {
|
||||
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.name),
|
||||
set: (value) => {
|
||||
@@ -71,7 +101,7 @@ const { containers: allContainers } = storeToRefs(store);
|
||||
|
||||
const otherContainers = computed(() =>
|
||||
allContainers.value
|
||||
.filter((c) => c.name === container.name && c.id !== container.id)
|
||||
.filter((c) => c.name === container.name && c.id !== container.id && c.customGroup === container.customGroup)
|
||||
.sort((a, b) => +b.created - +a.created),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
|
||||
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
||||
|
||||
import { Container } from "@/models/Container";
|
||||
import { lightTheme } from "@/stores/settings";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -16,7 +17,7 @@ vi.mock("vue-router");
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
@@ -119,4 +120,28 @@ describe("<FuzzySearchModal />", () => {
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
||||
});
|
||||
|
||||
test("matches commands by keyword", async () => {
|
||||
const wrapper = createFuzzySearchModal();
|
||||
await wrapper.find("input").setValue("theme");
|
||||
const items = wrapper.findAll("li").map((li) => li.text());
|
||||
expect(items).toContain("command-palette.theme-dark");
|
||||
});
|
||||
|
||||
test("theme commands set the theme explicitly", async () => {
|
||||
lightTheme.value = "auto";
|
||||
const wrapper = createFuzzySearchModal();
|
||||
|
||||
await wrapper.find("input").setValue("dark theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("dark");
|
||||
|
||||
await wrapper.find("input").setValue("light theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("light");
|
||||
|
||||
await wrapper.find("input").setValue("system theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
tabindex="0"
|
||||
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
||||
ref="input"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, totalCount - 1)"
|
||||
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||
@keydown.enter.exact="onEnter"
|
||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
||||
@keydown.alt.enter.exact.prevent="onPin"
|
||||
v-model="query"
|
||||
:placeholder="placeholderCopy"
|
||||
/>
|
||||
@@ -29,51 +29,80 @@
|
||||
|
||||
<!-- Body: results + log search CTA. Only renders when there is something
|
||||
to show — keeps the empty modal compact. -->
|
||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Containers section -->
|
||||
<template v-if="results.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
<div v-if="totalCount || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Scroll container spans both sections so the flat Commands + Containers
|
||||
list scrolls as one, matching the unified selection index. -->
|
||||
<div class="max-h-[50vh] overflow-y-auto overscroll-contain">
|
||||
<!-- Commands section -->
|
||||
<template v-if="commandEntries.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("command-palette.section-commands") }} · {{ commandEntries.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(command, index) in commandEntries" :ref="(el) => setItemRef(el, index)">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="runCommand(command)"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<component :is="command.icon" class="text-base-content/60 size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ command.title }}</span>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="text-base-content/40 size-4" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Containers section -->
|
||||
<template v-if="containerEntries.length">
|
||||
<div
|
||||
class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase"
|
||||
:class="{ 'border-base-content/10 mt-1 border-t': commandEntries.length }"
|
||||
>
|
||||
{{ $t("cloud-search.containers-section") }} · {{ containerEntries.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li
|
||||
v-for="(result, index) in containerEntries"
|
||||
:ref="(el) => setItemRef(el, commandEntries.length + index)"
|
||||
>
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': commandEntries.length + index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="commandEntries.length + index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Log search CTA -->
|
||||
<div
|
||||
v-if="logSearchVisible"
|
||||
@@ -130,7 +159,7 @@
|
||||
<div
|
||||
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
|
||||
>
|
||||
<span v-if="results.length" class="flex items-center gap-1.5">
|
||||
<span v-if="totalCount" class="flex items-center gap-1.5">
|
||||
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
||||
</span>
|
||||
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
||||
@@ -164,6 +193,7 @@ import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
import { type FuseResult } from "fuse.js";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||
import { useCommands, type Command } from "@/composable/commands";
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
@@ -176,9 +206,15 @@ const route = useRoute();
|
||||
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
||||
const query = ref(initialQuery);
|
||||
const input = ref<HTMLInputElement>();
|
||||
const listItems = ref<HTMLInputElement[]>();
|
||||
const listItems = ref<(Element | null)[]>([]);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
// Function ref into a single flat array so Commands and Containers share one
|
||||
// selection index for arrow-key navigation and scroll-into-view.
|
||||
function setItemRef(el: any, index: number) {
|
||||
listItems.value[index] = (el?.$el ?? el) as Element | null;
|
||||
}
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
@@ -268,6 +304,20 @@ const { results: fuseResults } = useFuse(query, list, {
|
||||
|
||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
||||
|
||||
// Commands palette. Fuzzy-matched against title/keywords while typing; the
|
||||
// context commands (container actions) show up front on an empty query.
|
||||
const { commands, contextCommands } = useCommands();
|
||||
const { results: commandFuseResults } = useFuse(query, commands, {
|
||||
fuseOptions: {
|
||||
keys: ["title", "keywords"],
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
},
|
||||
});
|
||||
const commandEntries = computed<Command[]>(() =>
|
||||
query.value ? commandFuseResults.value.map((r) => r.item) : contextCommands.value,
|
||||
);
|
||||
|
||||
const data = computed(() => {
|
||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||
if (a.score === b.score) {
|
||||
@@ -284,14 +334,26 @@ const data = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(query, (data) => {
|
||||
if (data.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
// Container hits, mirrors the previously named `data` list for the template.
|
||||
const containerEntries = computed(() => data.value);
|
||||
const totalCount = computed(() => commandEntries.value.length + containerEntries.value.length);
|
||||
|
||||
// Reset to the top only when the user types. Live SSE container add/remove
|
||||
// changes totalCount too, and resetting on that would snap the selection back
|
||||
// to 0 while the palette is open.
|
||||
watch(query, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
// Keep the selection in bounds when the result count shrinks underneath it.
|
||||
watch(totalCount, (count) => {
|
||||
if (selectedIndex.value > count - 1) {
|
||||
selectedIndex.value = Math.max(count - 1, 0);
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
||||
listItems.value?.[selectedIndex.value]?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
function selected(item: Item) {
|
||||
@@ -305,17 +367,35 @@ function selected(item: Item) {
|
||||
close();
|
||||
}
|
||||
|
||||
async function runCommand(command: Command) {
|
||||
close();
|
||||
await command.perform();
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
// Plain Enter prefers a container match if one is selected. With no
|
||||
// container matches (cloud-only query like "OOM"), fall back to log search
|
||||
// so the user isn't stuck on a popup that does nothing.
|
||||
if (data.value.length > 0) {
|
||||
selected(data.value[selectedIndex.value].item);
|
||||
// Commands come first in the flat list, then containers. With nothing
|
||||
// selectable (cloud-only query like "OOM"), fall back to log search so the
|
||||
// user isn't stuck on a popup that does nothing.
|
||||
const commandCount = commandEntries.value.length;
|
||||
if (selectedIndex.value < commandCount) {
|
||||
runCommand(commandEntries.value[selectedIndex.value]);
|
||||
} else if (containerEntries.value.length > 0) {
|
||||
selected(containerEntries.value[selectedIndex.value - commandCount].item);
|
||||
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
||||
runLogSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function onPin() {
|
||||
// Alt+Enter pins a container column. Only meaningful when a container row is
|
||||
// selected, not a command.
|
||||
const commandCount = commandEntries.value.length;
|
||||
if (selectedIndex.value >= commandCount) {
|
||||
const entry = containerEntries.value[selectedIndex.value - commandCount];
|
||||
if (entry?.item.type === "container") addColumn(entry.item);
|
||||
}
|
||||
}
|
||||
|
||||
function runLogSearch() {
|
||||
if (!cloudSearch.available.value) return;
|
||||
const q = query.value.trim();
|
||||
|
||||
@@ -78,8 +78,8 @@
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in owners" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
@@ -95,8 +95,8 @@
|
||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<mdi:cog class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<dropdown class="dropdown-end" v-if="config.user">
|
||||
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
||||
<template #trigger>
|
||||
<template v-if="config.disableAvatars || !config.user.email">
|
||||
<material-symbols:person class="size-6" />
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: GroupedLogEntry;
|
||||
@@ -32,6 +25,4 @@ const getPosition = (index: number): Position => {
|
||||
if (index === len - 1) return "end";
|
||||
return "middle";
|
||||
};
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@mouseenter="checkDropdownPosition"
|
||||
>
|
||||
<router-link
|
||||
v-if="isSearching"
|
||||
v-if="isFiltered"
|
||||
@click="resetSearch()"
|
||||
tabindex="0"
|
||||
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
|
||||
@@ -31,7 +31,7 @@
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="isSearching">
|
||||
<li v-if="isFiltered">
|
||||
<router-link
|
||||
@click="resetSearch()"
|
||||
:to="{
|
||||
@@ -99,8 +99,13 @@ const { showToast } = useToast();
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
const { isSearching, resetSearch } = useSearchFilter();
|
||||
const { levels } = useLoggingContext();
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
// Show "see in context" whenever the stream is narrowed, either by a text search
|
||||
// or by a log-level filter, so the entry can be inspected in the full log stream.
|
||||
const isFiltered = computed(() => isSearching.value || allLevels.some((level) => !levels.value.has(level)));
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard({ legacy: true });
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessage() {
|
||||
|
||||
@@ -48,6 +48,25 @@
|
||||
}}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end shrink-0" v-if="canExport">
|
||||
<div tabindex="0" role="button" class="btn btn-xs btn-ghost cursor-pointer gap-1">
|
||||
<ph:download-simple class="size-4" />
|
||||
{{ $t("analytics.export") }}
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-30 w-44 p-2 shadow-sm">
|
||||
<li>
|
||||
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('csv')">{{
|
||||
$t("analytics.export_csv")
|
||||
}}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('json')">{{
|
||||
$t("analytics.export_json")
|
||||
}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -148,33 +167,9 @@ onMounted(async () => {
|
||||
await db.registerFileBuffer("logs.json", arrayBuffer);
|
||||
|
||||
state.value = "initializing";
|
||||
|
||||
// Uniform JSON messages flatten into a STRUCT and unnest() expands them into columns.
|
||||
// Mixed message shapes make DuckDB infer `m` as JSON, which unnest() rejects, so fall
|
||||
// back to flattening the union of object keys by hand.
|
||||
const source = `read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`;
|
||||
try {
|
||||
await conn.query(`CREATE TABLE logs AS SELECT unnest(m) FROM ${source}`);
|
||||
} catch {
|
||||
await conn.query(`DROP TABLE IF EXISTS logs`);
|
||||
await conn.query(`CREATE TABLE raw_logs AS SELECT m::JSON AS m FROM ${source}`);
|
||||
const keyRows = await conn.query<{ key: any }>(
|
||||
`SELECT DISTINCT unnest(json_keys(m)) AS key FROM raw_logs WHERE json_type(m) = 'OBJECT'`,
|
||||
);
|
||||
const keys = keyRows.toArray().map((row) => String(row.key));
|
||||
if (keys.length) {
|
||||
const projection = keys
|
||||
.map((key) => {
|
||||
const ident = key.replace(/"/g, '""');
|
||||
const path = `$."${ident}"`.replace(/'/g, "''");
|
||||
return `json_extract_string(m, '${path}') AS "${ident}"`;
|
||||
})
|
||||
.join(", ");
|
||||
await conn.query(`CREATE TABLE logs AS SELECT ${projection} FROM raw_logs WHERE json_type(m) = 'OBJECT'`);
|
||||
} else {
|
||||
await conn.query(`CREATE TABLE logs AS SELECT m FROM raw_logs`);
|
||||
}
|
||||
}
|
||||
await conn.query(
|
||||
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`,
|
||||
);
|
||||
|
||||
const described = await conn.query<{ column_name: any; column_type: any }>(`DESCRIBE logs`);
|
||||
columns.value = described.toArray().map((row) => ({
|
||||
@@ -262,5 +257,54 @@ whenever(evaluating, () => {
|
||||
const page = computed(() =>
|
||||
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
||||
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
||||
|
||||
const canExport = computed(() => state.value === "ready" && !evaluating.value && results.value.numRows > 0);
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "object") return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toCSV(table: Table<Record<string, any>>, columns: string[]): string {
|
||||
const escape = (value: string) => (/[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
|
||||
const lines = [columns.map(escape).join(",")];
|
||||
for (const row of table) {
|
||||
lines.push(columns.map((column) => escape(stringify((row as Record<string, any>)[column]))).join(","));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toJSON(table: Table<Record<string, any>>, columns: string[]): string {
|
||||
const rows = [];
|
||||
for (const row of table) {
|
||||
const record: Record<string, unknown> = {};
|
||||
for (const column of columns) {
|
||||
const value = (row as Record<string, any>)[column];
|
||||
record[column] = typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
rows.push(record);
|
||||
}
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}
|
||||
|
||||
function exportResults(format: "csv" | "json") {
|
||||
const table = results.value as unknown as Table<Record<string, any>>;
|
||||
if (table.numRows === 0) return;
|
||||
|
||||
const columns = Object.keys(table.get(0) as Record<string, any>);
|
||||
const content = format === "csv" ? toCSV(table, columns) : toJSON(table, columns);
|
||||
const type = format === "csv" ? "text/csv;charset=utf-8" : "application/json";
|
||||
const name = container.name.replace(/[^\w.-]+/g, "-");
|
||||
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${name}-query.${format}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -9,17 +9,8 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
}>();
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
|
||||
@@ -7,17 +7,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
@@ -48,17 +48,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
@@ -89,17 +89,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.event-fields-hint", {
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, exitCode, signal, etc.)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
data-testid="scrollable-header"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[var(--mobile-nav-height)] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
|
||||
<div class="p-2">
|
||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: '/' }">
|
||||
<Logo class="h-10" />
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
<template>
|
||||
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
||||
<div class="modal-box" :width="width">
|
||||
<div class="modal-box" :width="maximized ? 'full' : width">
|
||||
<div class="pt-safe relative">
|
||||
<form method="dialog" class="absolute right-0">
|
||||
<button v-if="isMobile">
|
||||
<mdi:close />
|
||||
<div class="absolute right-0 flex items-center gap-3">
|
||||
<button
|
||||
v-if="!isMobile"
|
||||
class="hover:text-base-content/60 cursor-pointer outline-hidden"
|
||||
type="button"
|
||||
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||
@click="maximized = !maximized"
|
||||
>
|
||||
<mdi:arrow-collapse v-if="maximized" />
|
||||
<mdi:arrow-expand v-else />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
<form method="dialog">
|
||||
<button v-if="isMobile" class="cursor-pointer">
|
||||
<mdi:close />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active cursor-pointer outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<slot v-if="open" :close="close"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,6 +37,7 @@ import { type DrawerWidth } from "@/composable/drawer";
|
||||
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
||||
|
||||
const open = ref(false);
|
||||
const maximized = ref(false);
|
||||
const { width } = defineProps<{
|
||||
width: DrawerWidth;
|
||||
}>();
|
||||
@@ -35,6 +49,7 @@ function close() {
|
||||
defineExpose({
|
||||
open: () => {
|
||||
open.value = true;
|
||||
maximized.value = false;
|
||||
panel.value?.showModal();
|
||||
},
|
||||
close,
|
||||
@@ -55,6 +70,10 @@ useEventListener(panel, "close", () => (open.value = false));
|
||||
&[width="lg"] {
|
||||
@apply max-w-5xl;
|
||||
}
|
||||
|
||||
&[width="full"] {
|
||||
@apply w-full max-w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-right[open] .modal-box {
|
||||
|
||||
@@ -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 {
|
||||
const labels = computed(() => `namespace:${namespace.value.name}`);
|
||||
const labels = computed(() => `@k8s.namespace:${namespace.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
|
||||
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
|
||||
export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
|
||||
const labels = computed(() => `${owner.value.label}:true`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ export function createEventHints(): Completion[] {
|
||||
return [
|
||||
{ label: "name", detail: "event name", type: "property" },
|
||||
{ label: "attributes", detail: "event attributes map", type: "property" },
|
||||
{ label: 'attributes["healthStatus"]', detail: "healthy or unhealthy (health_status events)", type: "property" },
|
||||
{ label: 'attributes["exitCode"]', detail: "exit code (die events)", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: '"start"', detail: "container started", type: "string" },
|
||||
{ label: '"stop"', detail: "container stopped", type: "string" },
|
||||
@@ -114,6 +116,12 @@ export function createEventHints(): Completion[] {
|
||||
{ label: '"health_status"', detail: "health check changed", type: "string" },
|
||||
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
|
||||
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
|
||||
{
|
||||
label: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
|
||||
detail: "match unhealthy containers",
|
||||
type: "text",
|
||||
boost: 10,
|
||||
},
|
||||
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<div class="modal-box max-w-2xl overflow-visible! bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
@@ -100,7 +100,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
padding-top: var(--mobile-nav-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,6 +57,24 @@
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
/* ANSI log palette tuned for legibility on the dark background */
|
||||
--ansi-black: oklch(45% 0 0);
|
||||
--ansi-red: oklch(65% 0.2 25);
|
||||
--ansi-green: oklch(72% 0.16 150);
|
||||
--ansi-yellow: oklch(80% 0.13 85);
|
||||
--ansi-blue: oklch(70% 0.14 250);
|
||||
--ansi-magenta: oklch(68% 0.19 330);
|
||||
--ansi-cyan: oklch(75% 0.11 200);
|
||||
--ansi-white: oklch(89% 0 0);
|
||||
--ansi-bright-black: oklch(60% 0 0);
|
||||
--ansi-bright-red: oklch(72% 0.2 25);
|
||||
--ansi-bright-green: oklch(82% 0.17 150);
|
||||
--ansi-bright-yellow: oklch(88% 0.14 95);
|
||||
--ansi-bright-blue: oklch(78% 0.13 250);
|
||||
--ansi-bright-magenta: oklch(75% 0.19 330);
|
||||
--ansi-bright-cyan: oklch(85% 0.11 200);
|
||||
--ansi-bright-white: oklch(100% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
@@ -97,6 +115,24 @@
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
/* ANSI log palette tuned for legibility on the light background */
|
||||
--ansi-black: oklch(25% 0 0);
|
||||
--ansi-red: oklch(52% 0.2 25);
|
||||
--ansi-green: oklch(52% 0.15 150);
|
||||
--ansi-yellow: oklch(60% 0.13 75);
|
||||
--ansi-blue: oklch(50% 0.17 255);
|
||||
--ansi-magenta: oklch(50% 0.2 330);
|
||||
--ansi-cyan: oklch(55% 0.1 210);
|
||||
--ansi-white: oklch(70% 0 0);
|
||||
--ansi-bright-black: oklch(45% 0 0);
|
||||
--ansi-bright-red: oklch(58% 0.21 25);
|
||||
--ansi-bright-green: oklch(58% 0.16 150);
|
||||
--ansi-bright-yellow: oklch(62% 0.13 85);
|
||||
--ansi-bright-blue: oklch(55% 0.16 255);
|
||||
--ansi-bright-magenta: oklch(56% 0.2 330);
|
||||
--ansi-bright-cyan: oklch(60% 0.1 210);
|
||||
--ansi-bright-white: oklch(85% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
@@ -120,6 +156,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* daisyUI darkens the ghost button's border on hover, which reads as a
|
||||
* near-black ring on the dark theme. Use a theme-aware base-content tint so the
|
||||
* hover ring stays visible and legible in both themes (light on dark, dark on
|
||||
* light) instead of a fixed near-black. */
|
||||
.btn-ghost:hover {
|
||||
border-color: color-mix(in oklab, var(--color-base-content) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
@utility menu-active {
|
||||
&,
|
||||
&:active {
|
||||
@@ -143,6 +187,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Reserve the document scrollbar gutter so the layout width stays constant when
|
||||
* a page-level scrollbar appears (e.g. once a container's logs load). Without
|
||||
* this, the width jump makes the percentage-based splitpanes recompute and the
|
||||
* sidebar visibly flicks. */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
@@ -150,6 +202,13 @@ body {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Height of the mobile top nav (logo row + border). The container status bar
|
||||
* sticks right below it and the log content is padded by the same amount, so
|
||||
* they stay locked together. The safe-area inset is already handled by body. */
|
||||
:root {
|
||||
--mobile-nav-height: 57px;
|
||||
}
|
||||
|
||||
[class*="shadow-"] {
|
||||
@apply shadow-base-content/8;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { owners } = storeToRefs(k8sStore);
|
||||
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
|
||||
const ownerKey = computed(() => {
|
||||
try {
|
||||
return decodeURIComponent(String(route.params.name));
|
||||
} catch {
|
||||
return String(route.params.name);
|
||||
}
|
||||
});
|
||||
const owner = computed(() => owners.value.find((o) => o.key === ownerKey.value));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
export type K8sOwnerRef = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export class K8sNamespace {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -22,6 +30,9 @@ export class K8sOwner {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly kind: string,
|
||||
public readonly namespaceName: string | undefined,
|
||||
public readonly key: string,
|
||||
public readonly label: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
|
||||
@@ -32,6 +43,54 @@ export class K8sOwner {
|
||||
}
|
||||
}
|
||||
|
||||
export function ownerMembershipLabel(key: string) {
|
||||
const bytes = new TextEncoder().encode(key);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return `@k8s.owner.key.${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export function getK8sOwnerRefs(container: Container): K8sOwnerRef[] {
|
||||
const count = Number(container.labels["@k8s.owner.count"] ?? 0);
|
||||
if (count > 0) {
|
||||
const owners: K8sOwnerRef[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prefix = `@k8s.owner.${i}.`;
|
||||
const kind = container.labels[`${prefix}kind`];
|
||||
const name = container.labels[`${prefix}name`];
|
||||
const namespace = container.labels[`${prefix}namespace`];
|
||||
if (!kind || !name) continue;
|
||||
const key = container.labels[`${prefix}key`] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||
owners.push({ key, label: ownerMembershipLabel(key), kind, name, namespace });
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
// "~" matches the backend owner-key delimiter: URL-safe and invalid in Kubernetes names.
|
||||
const kind = container.labels["owner.kind"];
|
||||
const name = container.labels["owner.name"];
|
||||
if (!kind || !name) return [];
|
||||
|
||||
const namespace = container.labels["namespace"];
|
||||
const key = container.labels["owner.key"] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||
return [{ key, label: ownerMembershipLabel(key), kind, name, namespace }];
|
||||
}
|
||||
|
||||
export function groupK8sOwners(containers: Container[]) {
|
||||
const ownerGroups: Record<string, { owner: K8sOwnerRef; containers: Container[] }> = {};
|
||||
for (const container of containers) {
|
||||
for (const owner of getK8sOwnerRefs(container)) {
|
||||
ownerGroups[owner.key] ||= { owner, containers: [] };
|
||||
ownerGroups[owner.key].containers.push(container);
|
||||
}
|
||||
}
|
||||
return Object.values(ownerGroups).map(({ owner, containers }) => {
|
||||
return new K8sOwner(owner.name, owner.kind, owner.namespace, owner.key, owner.label, containers);
|
||||
});
|
||||
}
|
||||
|
||||
export const useK8sStore = defineStore("k8s", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
@@ -50,24 +109,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
const newNamespaces: K8sNamespace[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of containers) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
}
|
||||
|
||||
const newOwners: K8sOwner[] = [];
|
||||
|
||||
for (const [key, containers] of Object.entries(ownerGroups)) {
|
||||
const [kind, name] = key.split(":");
|
||||
newOwners.push(new K8sOwner(name, kind, containers));
|
||||
}
|
||||
const newOwners = groupK8sOwners(containers);
|
||||
|
||||
if (newOwners.length === 0) continue;
|
||||
|
||||
@@ -75,7 +117,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
new K8sNamespace(
|
||||
name,
|
||||
containers,
|
||||
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
newOwners.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -83,33 +125,23 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
const containersWithoutNamespace: Container[] = [];
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
const namespace = container.labels["namespace"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
if (namespace) {
|
||||
// Skip containers that are already part of a namespace
|
||||
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
||||
if (hasNamespace) continue;
|
||||
}
|
||||
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
containersWithoutNamespace.push(container);
|
||||
}
|
||||
|
||||
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
||||
|
||||
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
|
||||
const [kind, name] = key.split(":");
|
||||
return new K8sOwner(name, kind, containers);
|
||||
});
|
||||
const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
|
||||
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.key.localeCompare(b.key));
|
||||
});
|
||||
|
||||
const customGroups = computed(() => {
|
||||
|
||||
@@ -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 { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
||||
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:
|
||||
custom_base:
|
||||
container_name: custom_base
|
||||
@@ -10,6 +17,7 @@ services:
|
||||
- DOZZLE_HOSTNAME=custom name
|
||||
ports:
|
||||
- 8080:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
simple-auth:
|
||||
@@ -20,6 +28,7 @@ services:
|
||||
environment:
|
||||
- DOZZLE_AUTH_PROVIDER=simple
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
dozzle:
|
||||
@@ -33,8 +42,33 @@ services:
|
||||
- DOZZLE_LEVEL=debug
|
||||
ports:
|
||||
- 7070:8080
|
||||
# Deliberately no healthcheck: this container is the subject of the visual
|
||||
# snapshots, and a healthcheck makes a health badge appear in the sidebar
|
||||
# (HostMenu.vue), which shifts the reference images.
|
||||
build:
|
||||
context: .
|
||||
# Dedicated instance + noisy container so log streaming can be asserted without
|
||||
# perturbing the `name=dozzle` filter the other instances rely on.
|
||||
logs-viewer:
|
||||
container_name: logs-viewer
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DOZZLE_FILTER=name=logspam
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
- DOZZLE_HOSTNAME=logs
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
- logspam
|
||||
logspam:
|
||||
container_name: logspam
|
||||
image: alpine:3
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
|
||||
remote:
|
||||
container_name: remote
|
||||
environment:
|
||||
@@ -43,6 +77,7 @@ services:
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
ports:
|
||||
- 5050:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
@@ -56,6 +91,7 @@ services:
|
||||
- DOZZLE_LEVEL=debug
|
||||
ports:
|
||||
- 8082:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
@@ -85,6 +121,9 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- CONTAINERS=1
|
||||
# Dozzle calls /info on connect; without it the host never initializes and
|
||||
# lists zero containers.
|
||||
- INFO=1
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
||||
interval: 5s
|
||||
@@ -96,7 +135,7 @@ services:
|
||||
|
||||
playwright:
|
||||
container_name: playwright
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-jammy
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
@@ -105,6 +144,15 @@ services:
|
||||
- PWTEST_SKIP_TEST_OUTPUT=1
|
||||
- CI=1
|
||||
depends_on:
|
||||
- dozzle
|
||||
- custom_base
|
||||
- remote
|
||||
dozzle:
|
||||
condition: service_started
|
||||
custom_base:
|
||||
condition: service_healthy
|
||||
remote:
|
||||
condition: service_healthy
|
||||
simple-auth:
|
||||
condition: service_healthy
|
||||
dozzle-with-agent:
|
||||
condition: service_healthy
|
||||
logs-viewer:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
declare const data: { stars: number; pulls: number };
|
||||
declare const data: {
|
||||
stars: number;
|
||||
pulls: number;
|
||||
contributors: number;
|
||||
release: { version: string; date: string } | null;
|
||||
};
|
||||
export { data };
|
||||
|
||||
// The contributors endpoint has no total count, so ask for one per page and read
|
||||
// the last page number out of the Link header.
|
||||
async function fetchContributorCount() {
|
||||
const res = await fetch("https://api.github.com/repos/amir20/dozzle/contributors?per_page=1&anon=false");
|
||||
const match = res.headers.get("link")?.match(/[?&]page=(\d+)>; rel="last"/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease() {
|
||||
const res = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
|
||||
const json = await res.json();
|
||||
if (!json.tag_name) return null;
|
||||
return { version: json.tag_name, date: json.published_at };
|
||||
}
|
||||
|
||||
export default {
|
||||
async load() {
|
||||
const urls = [
|
||||
@@ -8,13 +28,18 @@ export default {
|
||||
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
|
||||
];
|
||||
|
||||
const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json())));
|
||||
const [repo, hub, contributors, release] = await Promise.all([
|
||||
...urls.map((url) => fetch(url).then((res) => res.json())),
|
||||
// Never fail the build over a rate-limited or flaky API call.
|
||||
fetchContributorCount().catch(() => 0),
|
||||
fetchLatestRelease().catch(() => null),
|
||||
]);
|
||||
|
||||
const data = {
|
||||
stars: responses[0].stargazers_count,
|
||||
pulls: responses[1].pull_count,
|
||||
return {
|
||||
stars: repo.stargazers_count,
|
||||
pulls: hub.pull_count,
|
||||
contributors,
|
||||
release,
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,14 @@ export default defineConfig({
|
||||
cleanUrls: true,
|
||||
|
||||
head: [
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:site_name", content: "Dozzle" }],
|
||||
["meta", { property: "og:url", content: "https://dozzle.dev/" }],
|
||||
["meta", { property: "og:image", content: "https://dozzle.dev/og-image.png" }],
|
||||
["meta", { property: "og:image:width", content: "1200" }],
|
||||
["meta", { property: "og:image:height", content: "630" }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:image", content: "https://dozzle.dev/og-image.png" }],
|
||||
[
|
||||
"script",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
import { withBase } from "vitepress";
|
||||
import InstallCommand from "./InstallCommand.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||
<div
|
||||
class="flex flex-col items-center rounded-2xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) px-6 py-16"
|
||||
>
|
||||
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
|
||||
Start watching your containers
|
||||
</h2>
|
||||
<p class="mt-3! mb-0! max-w-xl text-center text-(--vp-c-text-2)">
|
||||
One command, no agent to install, no account to create.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 w-full">
|
||||
<InstallCommand :hint="false" dense />
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<a class="cta-button cta-brand" :href="withBase('/guide/getting-started')">Get Started</a>
|
||||
<a class="cta-button cta-alt" href="https://github.com/amir20/dozzle">
|
||||
<Icon icon="simple-icons:github" class="size-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-8! mb-0! text-sm text-(--vp-c-text-2)">MIT licensed and free forever.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Mirror VitePress's own button tokens so these match the hero buttons in both themes. */
|
||||
.cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
text-decoration: none !important;
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
border-color 0.25s,
|
||||
color 0.25s;
|
||||
}
|
||||
|
||||
.cta-brand {
|
||||
border-color: var(--vp-button-brand-border);
|
||||
background-color: var(--vp-button-brand-bg);
|
||||
color: var(--vp-button-brand-text);
|
||||
}
|
||||
|
||||
.cta-brand:hover {
|
||||
border-color: var(--vp-button-brand-hover-border);
|
||||
background-color: var(--vp-button-brand-hover-bg);
|
||||
color: var(--vp-button-brand-hover-text);
|
||||
}
|
||||
|
||||
.cta-alt {
|
||||
border-color: var(--vp-button-alt-border);
|
||||
background-color: var(--vp-button-alt-bg);
|
||||
color: var(--vp-button-alt-text);
|
||||
}
|
||||
|
||||
.cta-alt:hover {
|
||||
border-color: var(--vp-button-alt-hover-border);
|
||||
background-color: var(--vp-button-alt-hover-bg);
|
||||
color: var(--vp-button-alt-hover-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { data } from "../../activity.data";
|
||||
|
||||
const cues = [
|
||||
{ icon: "mdi:scale-balance", label: "MIT licensed" },
|
||||
{ icon: "mdi:package-variant-closed", label: "Single binary, no database" },
|
||||
{ icon: "mdi:server-security", label: "Self-hosted, your logs stay on your network" },
|
||||
];
|
||||
|
||||
const released = computed(() => {
|
||||
if (!data.release) return null;
|
||||
const days = Math.floor((Date.now() - new Date(data.release.date).getTime()) / 86_400_000);
|
||||
if (days <= 0) return "released today";
|
||||
if (days === 1) return "released yesterday";
|
||||
if (days < 30) return `released ${days} days ago`;
|
||||
const months = Math.round(days / 30);
|
||||
return `released ${months} month${months === 1 ? "" : "s"} ago`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex flex-col gap-2 text-sm text-(--vp-c-text-2)">
|
||||
<ul class="m-0! flex list-none flex-wrap gap-x-5 gap-y-2 p-0!">
|
||||
<li v-for="cue in cues" :key="cue.label" class="flex items-center gap-1.5">
|
||||
<Icon :icon="cue.icon" class="size-4 opacity-70" />
|
||||
{{ cue.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
v-if="data.release"
|
||||
class="flex w-fit items-center gap-1.5 text-(--vp-c-text-3) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
:href="`https://github.com/amir20/dozzle/releases/tag/${data.release.version}`"
|
||||
>
|
||||
<span class="relative flex size-2">
|
||||
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60"></span>
|
||||
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
|
||||
</span>
|
||||
{{ data.release.version }} {{ released }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMutationObserver } from "@vueuse/core";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||
import { useData } from "vitepress";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const isDark = ref(false);
|
||||
onMounted(() => {
|
||||
isDark.value = document.documentElement.classList.contains("dark");
|
||||
useMutationObserver(
|
||||
document.documentElement,
|
||||
(mutations) => {
|
||||
isDark.value = document.documentElement.classList.contains("dark");
|
||||
},
|
||||
{
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
},
|
||||
);
|
||||
});
|
||||
import darkPoster from "../media/poster-dark.webp";
|
||||
import lightPoster from "../media/poster-light.webp";
|
||||
import darkVideo from "../media/dozzle-dark.mp4";
|
||||
import lightVideo from "../media/dozzle-light.mp4";
|
||||
|
||||
const { isDark } = useData();
|
||||
const reducedMotion = usePreferredReducedMotion();
|
||||
|
||||
// The posters are rendered on the server and toggled with CSS so the first paint
|
||||
// is already the right theme. The video only mounts on the client, which keeps
|
||||
// markup identical between SSR and hydration and avoids downloading both files.
|
||||
const mounted = ref(false);
|
||||
onMounted(() => (mounted.value = true));
|
||||
|
||||
const playVideo = computed(() => mounted.value && reducedMotion.value !== "reduce");
|
||||
const video = computed(() => (isDark.value ? darkVideo : lightVideo));
|
||||
const poster = computed(() => (isDark.value ? darkPoster : lightPoster));
|
||||
|
||||
const alt = "The Dozzle interface streaming container logs in real time";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] text-[red] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
class="relative overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
>
|
||||
<video muted loop autoplay playsinline poster="../media/poster-dark.png" v-if="isDark">
|
||||
<source src="../media/dozzle-dark.mp4" type="video/mp4" />
|
||||
<img src="../media/poster-dark.png" alt="" />
|
||||
</video>
|
||||
<video muted loop autoplay playsinline poster="../media/poster-light.png" v-else>
|
||||
<source src="../media/dozzle-light.mp4" type="video/mp4" />
|
||||
<img src="../media/poster-light.png" alt="" />
|
||||
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
|
||||
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
|
||||
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
|
||||
<video
|
||||
v-if="playVideo"
|
||||
:key="video"
|
||||
class="absolute inset-0 size-full"
|
||||
muted
|
||||
loop
|
||||
autoplay
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-hidden="true"
|
||||
:poster="poster"
|
||||
>
|
||||
<source :src="video" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
const { hint = true, dense = false } = defineProps<{
|
||||
hint?: boolean;
|
||||
dense?: boolean;
|
||||
}>();
|
||||
|
||||
const command =
|
||||
"docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest";
|
||||
|
||||
const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-[1152px] flex-col items-center gap-3 px-6" :class="dense ? 'mb-0' : 'mb-14'">
|
||||
<div
|
||||
class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4"
|
||||
>
|
||||
<span class="shrink-0 font-mono text-xs leading-6 text-(--vp-c-text-3) select-none md:text-sm">$</span>
|
||||
<code
|
||||
class="min-w-0 flex-1 bg-transparent! p-0! font-mono text-xs leading-6 break-words whitespace-pre-wrap text-(--vp-c-text-1) md:text-sm"
|
||||
>{{ command }}</code
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 cursor-pointer rounded-md border border-solid border-(--vp-c-divider) bg-(--vp-c-bg) px-3 py-1.5 text-xs font-medium text-(--vp-c-text-2) transition-colors hover:border-(--vp-c-brand) hover:text-(--vp-c-brand)"
|
||||
:aria-label="copied ? 'Command copied' : 'Copy command to clipboard'"
|
||||
@click="copy()"
|
||||
>
|
||||
{{ copied ? "Copied" : "Copy" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="hint" class="m-0! text-sm text-(--vp-c-text-2)">
|
||||
Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the
|
||||
<a :href="withBase('/guide/getting-started')">install guide</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { withBase } from "vitepress";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-16 mb-4 flex items-center justify-center px-6">
|
||||
<a
|
||||
class="flex items-center gap-2 text-sm text-(--vp-c-text-2) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
href="https://www.docker.com/blog/docker-sponsored-open-source-program/"
|
||||
>
|
||||
<img :src="withBase('/icons/docker-icon.svg')" width="20" height="20" alt="" />
|
||||
Open source and part of Docker's sponsored OSS program
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,53 @@
|
||||
<template>
|
||||
<div class="stats stats-horizontal mt-10">
|
||||
<div class="stat w-40 border-0!">
|
||||
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div>
|
||||
<div class="stat-value">
|
||||
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="(value) => (value / 1e3).toFixed(1) + 'K'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div>
|
||||
<div class="stat-value">
|
||||
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="(value) => (value / 1e6).toFixed(0) + 'M'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
|
||||
<a
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
:href="stat.href"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
|
||||
<Icon :icon="stat.icon" class="size-4 opacity-70" />
|
||||
{{ stat.label }}
|
||||
</span>
|
||||
<span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
|
||||
<Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { data } from "../../activity.data";
|
||||
import Counter from "./Counter.vue";
|
||||
|
||||
const stats = computed(() =>
|
||||
[
|
||||
{
|
||||
icon: "simple-icons:github",
|
||||
label: "Github Stars",
|
||||
value: data.stars,
|
||||
href: "https://github.com/amir20/dozzle/stargazers",
|
||||
},
|
||||
{
|
||||
icon: "simple-icons:docker",
|
||||
label: "Docker Pulls",
|
||||
value: data.pulls,
|
||||
href: "https://hub.docker.com/r/amir20/dozzle",
|
||||
},
|
||||
{
|
||||
icon: "mdi:account-group-outline",
|
||||
label: "Contributors",
|
||||
value: data.contributors,
|
||||
href: "https://github.com/amir20/dozzle/graphs/contributors",
|
||||
},
|
||||
].filter((stat) => stat.value),
|
||||
);
|
||||
|
||||
const compact = (value: number) => {
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B";
|
||||
if (value >= 1e6) return Math.round(value / 1e6) + "M";
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(1) + "K";
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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 BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
||||
import Stats from "./components/Stats.vue";
|
||||
import HeroTrust from "./components/HeroTrust.vue";
|
||||
import Supported from "./components/Supported.vue";
|
||||
import InstallCommand from "./components/InstallCommand.vue";
|
||||
import WhyDozzle from "./components/WhyDozzle.vue";
|
||||
import Testimonials from "./components/Testimonials.vue";
|
||||
import FinalCta from "./components/FinalCta.vue";
|
||||
import SponsoredBy from "./components/SponsoredBy.vue";
|
||||
|
||||
export default {
|
||||
...DefaultTheme,
|
||||
@@ -16,8 +22,9 @@ export default {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
"home-hero-image": () => h(HeroVideo),
|
||||
"sidebar-nav-after": () => h(BuyMeCoffee),
|
||||
"home-hero-actions-after": () => h(Stats),
|
||||
"home-hero-after": () => h(Supported),
|
||||
"home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
|
||||
"home-hero-after": () => [h(InstallCommand), h(Supported)],
|
||||
"home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
|
||||
});
|
||||
},
|
||||
enhanceApp(ctx) {
|
||||
|
||||
|
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 {
|
||||
@apply font-sans text-4xl font-light;
|
||||
@apply font-sans text-lg font-light md:text-2xl;
|
||||
color: var(--tag-line-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,16 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
||||
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
|
||||
FinalCta: typeof import('./.vitepress/theme/components/FinalCta.vue')['default']
|
||||
HeroTrust: typeof import('./.vitepress/theme/components/HeroTrust.vue')['default']
|
||||
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
|
||||
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
|
||||
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
|
||||
Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default']
|
||||
Testimonials: typeof import('./.vitepress/theme/components/Testimonials.vue')['default']
|
||||
WhyDozzle: typeof import('./.vitepress/theme/components/WhyDozzle.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,8 @@ Available properties:
|
||||
|
||||
Common Docker event names include `start`, `stop`, `die`, `kill`, `oom`, `restart`, `destroy`, and `health_status`.
|
||||
|
||||
For `health_status` events, Dozzle exposes the current state as `attributes["healthStatus"]` (`healthy` or `unhealthy`).
|
||||
|
||||
### Event Examples
|
||||
|
||||
**Alert when any production container dies:**
|
||||
@@ -247,7 +249,7 @@ Event: name == "oom"
|
||||
|
||||
```
|
||||
Container: true
|
||||
Event: name == "health_status" && attributes["health_status"] == "unhealthy"
|
||||
Event: name == "health_status" && attributes["healthStatus"] == "unhealthy"
|
||||
```
|
||||
|
||||
**Alert on unexpected exits (ignoring clean and graceful shutdowns):**
|
||||
|
||||
@@ -16,7 +16,7 @@ Dozzle has access to `docker.sock`, which — unless restricted — is equivalen
|
||||
- **Keep [actions](/guide/actions) and [shell access](/guide/shell) disabled** unless you need them. They allow starting, stopping, recreating, and executing arbitrary commands inside containers.
|
||||
- **Restrict users with [roles](#setting-specific-roles-for-users) and [filters](#setting-specific-filters-for-users)** in multi-user mode. Without explicit roles, a user can see every container the Dozzle instance can.
|
||||
- **Run TLS at the reverse proxy**. See [Reverse Proxy & Base Path](/guide/changing-base) for Nginx / Traefik / Caddy examples.
|
||||
- **Avoid mounting `docker.sock` read-write** if you don't need actions. A read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) still exposes most of the API but blocks container create/delete/update.
|
||||
- **Restrict `docker.sock` access with a proxy** if you don't need actions. Note that a read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) does _not_ limit the API: the `:ro` flag only marks the socket file read-only on disk, while API calls still pass through the socket normally, so create/delete/update remain possible. To actually restrict operations, put a socket proxy like [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy) in front of the daemon.
|
||||
|
||||
## <Icon icon="mdi:account-cog-outline" inline /> File-Based User Management
|
||||
|
||||
@@ -189,6 +189,14 @@ docker run -it --rm amir20/dozzle generate admin --password password --email tes
|
||||
|
||||
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. The `--user-roles` flag is a comma-separated list of roles.
|
||||
|
||||
If you omit `--password`, Dozzle prompts for it on stdin so the password never lands in your shell history. This requires an interactive terminal, so keep the `-it` flags:
|
||||
|
||||
```sh
|
||||
docker run -it --rm amir20/dozzle generate admin --email test@email.net --name "John Doe" > users.yml
|
||||
```
|
||||
|
||||
The prompt is written to stderr, so redirecting stdout to `users.yml` still works. You can also pipe the password in, for example `echo "$PASSWORD" | docker run -i --rm amir20/dozzle generate admin > users.yml`.
|
||||
|
||||
## <Icon icon="mdi:swap-horizontal" inline /> Forward Proxy
|
||||
|
||||
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
|
||||
|
||||
@@ -158,6 +158,12 @@ If you need a more opinionated experience with richer provider integrations (e.g
|
||||
|
||||
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
|
||||
|
||||
## Why does Dozzle keep dockerd and containerd at slightly elevated CPU when no browser is connected?
|
||||
|
||||
Dozzle keeps container stats streaming for up to 6 hours (2 hours on Kubernetes) after the last browser disconnects, then shuts the stats collector down on its own. This is intentional. Stats are streamed continuously so that when you reopen the UI you see historical CPU and memory history instead of a blank chart. If streaming stopped the moment you closed the tab, there would be no history to show.
|
||||
|
||||
The cost is a small, steady amount of CPU in dockerd and containerd, since Docker's stats API is polling based. Restarting the Dozzle container resets the timer immediately, which is why a restart drops the host back to idle. This is not configurable by design. A small timeout would break other functionality that assumes stats are still streaming, so lowering it defeats the purpose of the stats history.
|
||||
|
||||
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
|
||||
|
||||
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
|
||||
|
||||
@@ -26,6 +26,12 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
|
||||
@@ -39,6 +39,7 @@ All tools are **read-only** and do not modify containers.
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `list_containers` | List all containers across all hosts. Supports optional `state` filter. |
|
||||
| `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. |
|
||||
| `search_container_logs`| Search container logs for a keyword or phrase. Returns only matching entries. |
|
||||
| `list_hosts` | List all connected Docker hosts. |
|
||||
| `get_container_stats` | Get CPU and memory usage history for a container. |
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
title: Home
|
||||
titleTemplate: Real-time Docker Log Viewer
|
||||
description: Dozzle is a lightweight, open-source log viewer for Docker, Swarm, and Kubernetes. Stream logs, watch live stats, and debug containers from your browser.
|
||||
|
||||
hero:
|
||||
name: "Dozzle"
|
||||
text: "See what your containers are doing"
|
||||
tagline: Real-time Docker logs, stats, and debugging — in your browser.
|
||||
actions:
|
||||
- theme: brand
|
||||
@@ -22,12 +24,16 @@ features:
|
||||
src: /icons/document.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/what-is-dozzle#advanced-log-handling
|
||||
linkText: Learn More
|
||||
- title: Live Stats & Metrics
|
||||
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
|
||||
icon:
|
||||
src: /icons/chart-line-data.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/what-is-dozzle#real-time-monitoring
|
||||
linkText: Learn More
|
||||
- title: SQL Log Analysis
|
||||
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
|
||||
icon:
|
||||
@@ -52,12 +58,6 @@ features:
|
||||
height: 36
|
||||
link: /guide/remote-hosts
|
||||
linkText: Learn More
|
||||
- title: Self-hosted & Private
|
||||
details: Runs in your own infrastructure. Your logs never leave your network.
|
||||
icon:
|
||||
src: /icons/locked.svg
|
||||
width: 36
|
||||
height: 36
|
||||
- title: Shell & Exec Access
|
||||
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
|
||||
icon:
|
||||
@@ -66,12 +66,28 @@ features:
|
||||
height: 36
|
||||
link: /guide/shell
|
||||
linkText: Learn More
|
||||
- title: Officially Sponsored by Docker
|
||||
details: Open source and part of Docker's sponsored OSS program.
|
||||
- title: dtop in Your Terminal
|
||||
details: A command-line companion that gives you a live view of your containers, then jumps straight into Dozzle.
|
||||
icon:
|
||||
src: /icons/docker-icon.svg
|
||||
src: /icons/terminal-command.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: https://hub.docker.com/r/amir20/dozzle
|
||||
linkText: Docker Hub
|
||||
link: /guide/dtop
|
||||
linkText: Learn More
|
||||
- title: MCP for AI Assistants
|
||||
details: Expose containers, logs, and stats over the Model Context Protocol so your coding agent can debug alongside you.
|
||||
icon:
|
||||
src: /icons/ai.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/mcp
|
||||
linkText: Learn More
|
||||
- title: Self-hosted & Private
|
||||
details: Runs in your own infrastructure with simple or forward-proxy auth. Your logs never leave your network.
|
||||
icon:
|
||||
src: /icons/locked.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/authentication
|
||||
linkText: Learn More
|
||||
---
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dozzle": "workspace:*"
|
||||
},
|
||||
"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: [
|
||||
tailwindcss(),
|
||||
Components({
|
||||
dirs: [path.resolve(__dirname, ".vitepress/theme/components")],
|
||||
dirs: [path.resolve(import.meta.dirname, ".vitepress/theme/components")],
|
||||
extensions: ["vue", "md"],
|
||||
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
|
||||
dts: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://dozzle-with-agent:8080/");
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
const origin = "http://custom_base:8080";
|
||||
const base = "/foobarbase";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://custom_base:8080/foobarbase");
|
||||
await page.goto(origin + base);
|
||||
});
|
||||
|
||||
test("has right title", async ({ page }) => {
|
||||
@@ -11,3 +14,35 @@ test("has right title", async ({ page }) => {
|
||||
test("url should have custom base", async ({ page }) => {
|
||||
await expect(page).toHaveURL(/foobarbase/);
|
||||
});
|
||||
|
||||
test("static assets resolve under the custom base", async ({ page }) => {
|
||||
const requested: string[] = [];
|
||||
const broken: string[] = [];
|
||||
|
||||
const isAsset = (url: string) => new URL(url).pathname.includes("/assets/");
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (isAsset(request.url())) requested.push(new URL(request.url()).pathname);
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (isAsset(response.url()) && response.status() >= 400) {
|
||||
broken.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||
// fonts are only fetched when a glyph needs them, so force the @font-face src to resolve.
|
||||
// swallow the rejection on a bad url so the assertions below report which path was wrong
|
||||
await page.evaluate(() =>
|
||||
document.fonts.load('400 1rem "JetBrains Mono"').then(
|
||||
() => {},
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
// the css lives at <base>/assets/, so a relative url() in it must resolve back under <base>
|
||||
expect(requested.some((path) => path.endsWith(".woff2"))).toBe(true);
|
||||
expect(requested.filter((path) => !path.startsWith(`${base}/assets/`))).toEqual([]);
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
test("simple authentication", async ({ page }) => {
|
||||
await page.goto("http://simple-auth:8080/");
|
||||
const BASE = "http://simple-auth:8080";
|
||||
|
||||
async function login(page: import("@playwright/test").Page) {
|
||||
await page.locator('input[name="username"]').fill("admin");
|
||||
await page.locator('input[name="password"]').fill("password");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
}
|
||||
|
||||
test("simple authentication", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("settings")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated request redirects to the login page", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("preserves the requested url as redirectUrl", async ({ page }) => {
|
||||
await page.goto(BASE + "/settings");
|
||||
await expect(page).toHaveURL(/\/login\?redirectUrl=/);
|
||||
});
|
||||
|
||||
test("rejects a wrong password", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await page.locator('input[name="username"]').fill("admin");
|
||||
await page.locator('input[name="password"]').fill("not-the-password");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText("Username or password are not valid")).toBeVisible();
|
||||
await expect(page.getByTestId("settings")).toBeHidden();
|
||||
});
|
||||
|
||||
test("api rejects unauthenticated requests with 401", async ({ request }) => {
|
||||
// A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
|
||||
for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
|
||||
const response = await request.get(BASE + path, { maxRedirects: 0 });
|
||||
expect(response.status(), `${path} should be unauthorized`).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
test("logout clears the session", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("settings")).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-menu").locator("label").click();
|
||||
await page.getByRole("button", { name: "Logout" }).click();
|
||||
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
|
||||
// The cookie must be gone server-side, not just visually logged out.
|
||||
const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
|
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: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
|
||||
@@ -13,52 +13,55 @@ require (
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/compose-spec/compose-go/v2 v2.11.0
|
||||
github.com/andybalholm/brotli v1.2.2
|
||||
github.com/compose-spec/compose-go/v2 v2.14.0
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-faker/faker/v4 v4.7.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/go-faker/faker/v4 v4.10.1
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/moby/docker-image-spec v1.3.1
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/sync v0.22.0
|
||||
google.golang.org/grpc v1.83.0
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
||||
k8s.io/api v0.36.1
|
||||
k8s.io/apimachinery v0.36.1
|
||||
k8s.io/client-go v0.36.1
|
||||
k8s.io/metrics v0.36.1
|
||||
k8s.io/api v0.36.3
|
||||
k8s.io/apimachinery v0.36.3
|
||||
k8s.io/client-go v0.36.3
|
||||
k8s.io/metrics v0.36.3
|
||||
)
|
||||
|
||||
require github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
require (
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
golang.org/x/term v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/air-verse/air v1.65.3 // indirect
|
||||
github.com/air-verse/air v1.67.4 // indirect
|
||||
github.com/alexflint/go-scalar v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.5 // indirect
|
||||
github.com/buger/jsonparser v1.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
@@ -96,7 +99,7 @@ require (
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||
github.com/gohugoio/hugo v0.161.0 // indirect
|
||||
github.com/gohugoio/hugo v0.164.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
@@ -122,7 +125,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
@@ -141,23 +144,22 @@ require (
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // indirect
|
||||
k8s.io/streaming v0.36.1 // indirect
|
||||
k8s.io/streaming v0.36.3 // indirect
|
||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
@@ -165,7 +167,7 @@ require (
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.5
|
||||
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
|
||||
@@ -13,18 +13,20 @@ github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
|
||||
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.2/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
|
||||
github.com/air-verse/air v1.65.3/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.67.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.67.2/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||
github.com/air-verse/air v1.67.3/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||
github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
|
||||
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
|
||||
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
@@ -69,6 +71,7 @@ github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
|
||||
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -81,10 +84,10 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/compose-spec/compose-go/v2 v2.10.2 h1:USa1NUbDcl/cjb8T9iwnuFsnO79H+2ho2L5SjFKz3uI=
|
||||
github.com/compose-spec/compose-go/v2 v2.10.2/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
||||
github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM=
|
||||
github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
||||
github.com/compose-spec/compose-go/v2 v2.13.0 h1:2+2oS3v4SrtAOBdZRAZYBsBy47D571p5EXMSCppmTtE=
|
||||
github.com/compose-spec/compose-go/v2 v2.13.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
||||
github.com/compose-spec/compose-go/v2 v2.14.0 h1:uaJeo5B3+OVlu+Rx2qLBcAdXPEUUzm5nQrRiGJafRAQ=
|
||||
github.com/compose-spec/compose-go/v2 v2.14.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
@@ -132,24 +135,26 @@ github.com/getkin/kin-openapi v0.137.0 h1:Q3HhawNQV0GfvO2mIYMUBUSEFrDsVlzcYz4Vyd
|
||||
github.com/getkin/kin-openapi v0.137.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E=
|
||||
github.com/go-faker/faker/v4 v4.7.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
||||
github.com/go-faker/faker/v4 v4.9.0 h1:a4HXLwueuTCtgF93VpUsl8Zd2nG1VH2SgNWDPVEBg5U=
|
||||
github.com/go-faker/faker/v4 v4.9.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
||||
github.com/go-faker/faker/v4 v4.10.0 h1:rHTZVwG1x8aN4zXDYJUFucOGDQojuxOby5MTdC/M2Fw=
|
||||
github.com/go-faker/faker/v4 v4.10.0/go.mod h1:X+KzPB4JZ82GY4MYr7NV7zmp+i/K0SG5EDKqIg5zd1k=
|
||||
github.com/go-faker/faker/v4 v4.10.1 h1:mGH3y0X8AfrXsfgPsPuTkWBYIebomvdkWEN1uDLoFFg=
|
||||
github.com/go-faker/faker/v4 v4.10.1/go.mod h1:VFIEwWDd16EdYDLF6NJ5gAAzEp7vz5LgKgJ2iZ17Tdg=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
|
||||
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
||||
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -211,6 +216,7 @@ github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/Sfico
|
||||
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.161.0 h1:RnVIwSeTVDbhXbSwa+5C8QFkQVa634IdmekvSjLVMmI=
|
||||
github.com/gohugoio/hugo v0.161.0/go.mod h1:ZJStxHMZXnnhvCfOAy6FCLbWf90zTpH/cnvWAcmoyiE=
|
||||
github.com/gohugoio/hugo v0.164.0/go.mod h1:QPBCN6Gntmm6YrYJEnTasaZjnMLRkv8YoyCOw7mo9JE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
|
||||
@@ -297,16 +303,18 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
||||
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -345,6 +353,7 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
@@ -416,8 +425,10 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -426,18 +437,26 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8V
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -447,10 +466,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
@@ -460,8 +477,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
@@ -472,8 +489,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -483,8 +500,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -500,10 +517,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -513,8 +528,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -525,8 +540,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -535,19 +550,22 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -568,30 +586,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
|
||||
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
|
||||
k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
|
||||
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
|
||||
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
|
||||
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
|
||||
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
|
||||
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
|
||||
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
|
||||
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
|
||||
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
|
||||
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
|
||||
k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
|
||||
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
|
||||
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
|
||||
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
|
||||
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
|
||||
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
|
||||
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
|
||||
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
|
||||
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
|
||||
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
|
||||
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
|
||||
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ=
|
||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
|
||||
k8s.io/metrics v0.36.0 h1:VF41Mv9ZWKKQ4jEiJ0n3Tp6jdyO+oM6dbKcJn6Y/DVg=
|
||||
k8s.io/metrics v0.36.0/go.mod h1:FY1dgPJZqnSfnOYbVdBEdRNUdy0n1nUCU6yxSMUrVG4=
|
||||
k8s.io/metrics v0.36.1 h1:MQPb+G4RhrKEpt8NETPssbW8QgGUc4Jbqu1jx+kPqGk=
|
||||
k8s.io/metrics v0.36.1/go.mod h1:xqS8XcWLjDzo6E7DJm/GfjKpRKdN5/MtJAQFuV6nLUc=
|
||||
k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4=
|
||||
k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||
k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4=
|
||||
k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||
k8s.io/metrics v0.36.2 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8=
|
||||
k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0=
|
||||
k8s.io/metrics v0.36.3 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ=
|
||||
k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM=
|
||||
k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
|
||||
k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||
k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
|
||||
k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
|
||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
|
||||
@@ -15,6 +15,12 @@ type contextKey string
|
||||
|
||||
const remoteUser contextKey = "remoteUser"
|
||||
|
||||
// WithUser returns a copy of ctx carrying user so downstream handlers can
|
||||
// resolve it via UserFromContext.
|
||||
func WithUser(ctx context.Context, user User) context.Context {
|
||||
return context.WithValue(ctx, remoteUser, user)
|
||||
}
|
||||
|
||||
type proxyAuthContext struct {
|
||||
headerUser string
|
||||
headerEmail string
|
||||
@@ -55,7 +61,7 @@ func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler {
|
||||
userRoles = ParseRole(r.Header.Get(p.headerRoles))
|
||||
}
|
||||
user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter, userRoles)
|
||||
ctx := context.WithValue(r.Context(), remoteUser, user)
|
||||
ctx := WithUser(r.Context(), user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
} else {
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@@ -3,7 +3,9 @@ package auth
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
@@ -18,8 +20,13 @@ type simpleAuthContext struct {
|
||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
|
||||
func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext {
|
||||
// Hash the users in a stable order. Ranging over the map directly makes the
|
||||
// digest depend on Go's randomized map iteration order, so any users.yml with
|
||||
// more than one user derives a different signing key on every start and
|
||||
// silently invalidates every session on restart.
|
||||
h := sha256.New()
|
||||
for _, user := range userDatabase.Users {
|
||||
for _, username := range slices.Sorted(maps.Keys(userDatabase.Users)) {
|
||||
user := userDatabase.Users[username]
|
||||
h.Write([]byte(user.Password))
|
||||
h.Write([]byte(user.RolesConfigured))
|
||||
}
|
||||
@@ -39,7 +46,7 @@ func (a *simpleAuthContext) CreateToken(username, password string) (string, erro
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter, "roles": user.Roles}
|
||||
claims := map[string]any{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter, "roles": user.Roles}
|
||||
jwtauth.SetIssuedNow(claims)
|
||||
|
||||
if a.ttl > 0 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
ls.wg.Add(1)
|
||||
go func() {
|
||||
defer ls.wg.Done()
|
||||
ls.wg.Go(func() {
|
||||
defer func() {
|
||||
ls.mu.Lock()
|
||||
delete(ls.readers, key)
|
||||
@@ -172,7 +170,7 @@ func (ls *logStreamer) startReader(parent context.Context, c container.Container
|
||||
cancel()
|
||||
}()
|
||||
ls.runReader(readerCtx, cs, minRank)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// runReader follows logs from a single container and pushes batches directly
|
||||
|
||||
@@ -84,9 +84,21 @@ func executeStreamLogs(ctx context.Context, requestID string, argsJSON string, d
|
||||
|
||||
events := make(chan *container.LogEvent, 100)
|
||||
|
||||
// Both backends already cap a follow stream at the last N lines (Docker
|
||||
// Tail: 100, k8s TailLines: 500), so `from` only decides how many of those
|
||||
// survive the since-filter. now-30s threw nearly all of them away, which
|
||||
// left a quiet container showing an empty pane until it happened to log
|
||||
// something. Anchoring at the container's start lets the tail cap do the
|
||||
// work: last N lines, then live. Same value the web UI streams from, see
|
||||
// streamLogs in internal/web/logs.go.
|
||||
from := cs.Container.StartedAt
|
||||
if from.IsZero() {
|
||||
from = time.Now().Add(-30 * time.Second)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
if err := cs.StreamLogs(ctx, time.Now().Add(-30*time.Second), container.STDOUT|container.STDERR, events); err != nil {
|
||||
if err := cs.StreamLogs(ctx, from, container.STDOUT|container.STDERR, events); err != nil {
|
||||
log.Debug().Err(err).Str("container", cs.Container.Name).Msg("StreamLogs ended with error")
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -414,3 +414,55 @@ func TestExecuteStreamLogs_BatchingAt50(t *testing.T) {
|
||||
assert.Len(t, firstBatch.Entries, 50)
|
||||
assert.False(t, responses[0].GetCallTool().EndStream)
|
||||
}
|
||||
|
||||
func TestExecuteStreamLogs_StreamsFromContainerStart(t *testing.T) {
|
||||
// Docker caps the follow stream at the last 100 lines and k8s at 500, so
|
||||
// `from` is what decides how many of them survive the since-filter. Passing
|
||||
// a recent timestamp discarded the backlog and left the cloud dashboard on
|
||||
// an empty pane until the container next logged. Anchor at StartedAt so the
|
||||
// backend's tail cap delivers the tail it already fetched.
|
||||
startedAt := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
var gotFrom time.Time
|
||||
mockClient := &StreamMockClientService{}
|
||||
mockClient.streamFunc = func(_ context.Context, _ container.Container, from time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
|
||||
gotFrom = from
|
||||
events <- &container.LogEvent{RawMessage: "backlog", Level: "info", Stream: "stdout", Timestamp: 1000}
|
||||
return nil
|
||||
}
|
||||
|
||||
c := container.Container{ID: "abc123", Name: "test-container", Host: "host1", StartedAt: startedAt}
|
||||
mockHost := &MockHostService{}
|
||||
withResolver(mockHost, c)
|
||||
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).
|
||||
Return(container_support.NewContainerService(mockClient, c), nil)
|
||||
|
||||
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`,
|
||||
ToolDeps{HostService: mockHost}, func(*pb.ToolResponse) error { return nil })
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, startedAt, gotFrom)
|
||||
}
|
||||
|
||||
func TestExecuteStreamLogs_FallsBackWhenStartedAtUnknown(t *testing.T) {
|
||||
// A container with no StartedAt (agent gaps, odd backends) must still get a
|
||||
// usable window rather than the zero time, which would ask the daemon for
|
||||
// every line it has ever held.
|
||||
var gotFrom time.Time
|
||||
mockClient := &StreamMockClientService{}
|
||||
mockClient.streamFunc = func(_ context.Context, _ container.Container, from time.Time, _ container.StdType, _ chan<- *container.LogEvent) error {
|
||||
gotFrom = from
|
||||
return nil
|
||||
}
|
||||
|
||||
c := container.Container{ID: "abc123", Name: "test-container", Host: "host1"}
|
||||
mockHost := &MockHostService{}
|
||||
withResolver(mockHost, c)
|
||||
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).
|
||||
Return(container_support.NewContainerService(mockClient, c), nil)
|
||||
|
||||
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`,
|
||||
ToolDeps{HostService: mockHost}, func(*pb.ToolResponse) error { return nil })
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, gotFrom.IsZero())
|
||||
assert.WithinDuration(t, time.Now(), gotFrom, time.Minute)
|
||||
}
|
||||
|
||||
@@ -448,14 +448,21 @@ func (s *ContainerStore) init() {
|
||||
|
||||
case "rename":
|
||||
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
|
||||
if loaded {
|
||||
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
|
||||
copy := *c
|
||||
copy.Name = event.ActorAttributes["name"]
|
||||
return ©, xsync.UpdateOp
|
||||
} else {
|
||||
if !loaded {
|
||||
return c, xsync.CancelOp
|
||||
}
|
||||
// A dev.dozzle.name or coolify.serviceName label pins a custom
|
||||
// display name (see newContainer). That name must survive a
|
||||
// docker-level rename, so only follow the rename when the name
|
||||
// actually comes from Docker.
|
||||
if c.Labels["dev.dozzle.name"] != "" || c.Labels["coolify.serviceName"] != "" {
|
||||
log.Debug().Str("id", event.ActorID).Msg("ignoring rename: container has a custom name label")
|
||||
return c, xsync.CancelOp
|
||||
}
|
||||
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
|
||||
copy := *c
|
||||
copy.Name = event.ActorAttributes["name"]
|
||||
return ©, xsync.UpdateOp
|
||||
})
|
||||
}
|
||||
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
|
||||
|
||||
@@ -119,6 +119,80 @@ func TestContainerStore_die(t *testing.T) {
|
||||
assert.Equal(t, containers[0].State, "exited")
|
||||
}
|
||||
|
||||
func TestContainerStore_rename(t *testing.T) {
|
||||
run := func(t *testing.T, initial Container, attributes map[string]string) Container {
|
||||
client := new(mockedClient)
|
||||
client.On("ListContainers", mock.Anything, mock.Anything).Return([]Container{initial}, nil)
|
||||
client.On("FindContainer", mock.Anything, initial.ID).Return(initial, nil)
|
||||
client.On("Host").Return(Host{ID: "localhost"})
|
||||
|
||||
ready := make(chan struct{})
|
||||
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).
|
||||
Run(func(args mock.Arguments) {
|
||||
ctx := args.Get(0).(context.Context)
|
||||
events := args.Get(1).(chan<- ContainerEvent)
|
||||
<-ready
|
||||
events <- ContainerEvent{
|
||||
Name: "rename",
|
||||
ActorID: initial.ID,
|
||||
Host: "localhost",
|
||||
ActorAttributes: attributes,
|
||||
}
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
store := NewContainerStore(t.Context(), client, &fakeStatsCollector{}, ContainerLabels{})
|
||||
|
||||
events := make(chan ContainerEvent)
|
||||
store.SubscribeEvents(t.Context(), events)
|
||||
close(ready)
|
||||
<-events
|
||||
|
||||
containers, err := store.ListContainers(ContainerLabels{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, containers, 1)
|
||||
return containers[0]
|
||||
}
|
||||
|
||||
t.Run("keeps custom name from dev.dozzle.name label", func(t *testing.T) {
|
||||
initial := Container{
|
||||
ID: "1234",
|
||||
Name: "custom-name",
|
||||
State: "running",
|
||||
FullyLoaded: true,
|
||||
Labels: map[string]string{"dev.dozzle.name": "custom-name"},
|
||||
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||
}
|
||||
result := run(t, initial, map[string]string{"name": "new-docker-name"})
|
||||
assert.Equal(t, "custom-name", result.Name)
|
||||
})
|
||||
|
||||
t.Run("keeps custom name from coolify.serviceName label", func(t *testing.T) {
|
||||
initial := Container{
|
||||
ID: "1234",
|
||||
Name: "coolify-name",
|
||||
State: "running",
|
||||
FullyLoaded: true,
|
||||
Labels: map[string]string{"coolify.serviceName": "coolify-name"},
|
||||
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||
}
|
||||
result := run(t, initial, map[string]string{"name": "new-docker-name"})
|
||||
assert.Equal(t, "coolify-name", result.Name)
|
||||
})
|
||||
|
||||
t.Run("follows rename when name comes from docker", func(t *testing.T) {
|
||||
initial := Container{
|
||||
ID: "1234",
|
||||
Name: "old-docker-name",
|
||||
State: "running",
|
||||
FullyLoaded: true,
|
||||
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||
}
|
||||
result := run(t, initial, map[string]string{"name": "new-docker-name"})
|
||||
assert.Equal(t, "new-docker-name", result.Name)
|
||||
})
|
||||
}
|
||||
|
||||
type fakeStatsCollector struct{}
|
||||
|
||||
func (f *fakeStatsCollector) Subscribe(_ context.Context, _ chan<- ContainerStat) {}
|
||||
|
||||
@@ -117,8 +117,7 @@ func TestEventGenerator_doesNotStallOnSustainedLevellessStream(t *testing.T) {
|
||||
// maxGroupTimeDelta, every line looks like an orphaned continuation, so the
|
||||
// skip loop buffers forever and the UI shows "no logs". The generator must
|
||||
// give up and emit instead.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx := t.Context()
|
||||
|
||||
reader := &steadyLevellessReader{ctx: ctx, base: time.Now(), step: time.Millisecond, delay: time.Millisecond}
|
||||
// startedAt far in the past so the near-start short-circuit cannot fire
|
||||
|
||||
@@ -15,7 +15,7 @@ var SupportedLogLevels map[string]struct{}
|
||||
var logLevels = [][]string{
|
||||
{"error", "err"},
|
||||
{"warn", "warning", "wrn"},
|
||||
{"info", "inf"},
|
||||
{"info", "inf", "information"},
|
||||
{"debug", "dbg"},
|
||||
{"trace", "verbose", "ver", "vbs"},
|
||||
{"fatal", "sev", "severe", "crit", "critical"},
|
||||
|
||||
@@ -115,6 +115,20 @@ func TestGuessLogLevel(t *testing.T) {
|
||||
{"WARN: connection error: retrying", "warn"},
|
||||
// Symmetric: an ERROR prefix still wins over a later info word.
|
||||
{"ERROR: handler failed, info: will retry", "error"},
|
||||
// .NET / Serilog / Microsoft.Extensions.Logging spell out "Information".
|
||||
{"Information: service started", "info"},
|
||||
{"[Information] service started", "info"},
|
||||
{"2024-12-30T17:43:16Z Information starting up", "info"},
|
||||
{orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "level", Value: "Information"},
|
||||
),
|
||||
), "info"},
|
||||
{orderedmap.New[string, any](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, any]{Key: "@l", Value: "Information"},
|
||||
),
|
||||
), "info"},
|
||||
// Equal confidence between two different levels -> unknown (don't guess).
|
||||
{"saw info: here and error: there", "unknown"},
|
||||
{"[INFO] [DEBUG] both bracketed", "unknown"},
|
||||
|
||||
@@ -2,6 +2,8 @@ package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
@@ -36,7 +38,7 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
||||
} else if char == '\\' {
|
||||
escaping = true
|
||||
} else if char == '"' {
|
||||
value = log[start:i]
|
||||
value = unescapeQuoted(log[start-1 : i+1])
|
||||
result.Set(key, value)
|
||||
inQuotes = false
|
||||
isKey = true
|
||||
@@ -77,3 +79,19 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// unescapeQuoted decodes a quoted logfmt value (including the surrounding
|
||||
// quotes) so escape sequences like \" and \\ produced by logfmt encoders
|
||||
// (logrus, go-kit, go-logfmt) yield the original text instead of leaking
|
||||
// backslashes into the parsed value. If the sequence is not decodable, the
|
||||
// raw content between the quotes is returned unchanged.
|
||||
func unescapeQuoted(quoted string) string {
|
||||
raw := quoted[1 : len(quoted)-1]
|
||||
if !strings.ContainsRune(raw, '\\') {
|
||||
return raw
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(quoted); err == nil {
|
||||
return unquoted
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -67,6 +67,28 @@ func TestParseLog(t *testing.T) {
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Escaped quotes and backslashes in quoted values",
|
||||
log: `level=info msg="failed to open \"config.yml\"" path="C:\\temp"`,
|
||||
want: orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "level", Value: "info"},
|
||||
orderedmap.Pair[string, string]{Key: "msg", Value: `failed to open "config.yml"`},
|
||||
orderedmap.Pair[string, string]{Key: "path", Value: `C:\temp`},
|
||||
),
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Quoted value with undecodable escape is kept as-is",
|
||||
log: `msg="foo \q bar"`,
|
||||
want: orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "msg", Value: `foo \q bar`},
|
||||
),
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Broken format with unexpected quotes",
|
||||
log: `key1=value"1"= key2="value2"`,
|
||||
|
||||
@@ -2,20 +2,46 @@ package container
|
||||
|
||||
import "strings"
|
||||
|
||||
// Highlight markers that Dozzle injects into log messages for HTML rendering
|
||||
// (see internal/support/web: MarkerStart/End = U+E000/U+E001 for search hits,
|
||||
// URLMarkerStart/End = U+E002/U+E003 for URLs). They live in the Unicode
|
||||
// private-use area and must never appear in copied or downloaded plain-text
|
||||
// logs, where they show up as invisible garbage characters.
|
||||
const (
|
||||
markerSearchStart = ""
|
||||
markerSearchEnd = ""
|
||||
markerURLStart = ""
|
||||
markerURLEnd = ""
|
||||
)
|
||||
|
||||
var highlightMarkerStripper = strings.NewReplacer(
|
||||
markerSearchStart, "",
|
||||
markerSearchEnd, "",
|
||||
markerURLStart, "",
|
||||
markerURLEnd, "",
|
||||
)
|
||||
|
||||
// StripHighlightMarkers removes the internal search/URL highlight markers that
|
||||
// Dozzle injects for HTML rendering. Paths that emit raw log text (clipboard
|
||||
// copy, downloads) must call it so the markers don't leak into the output.
|
||||
func StripHighlightMarkers(s string) string {
|
||||
return highlightMarkerStripper.Replace(s)
|
||||
}
|
||||
|
||||
// PlainText renders a log event as plain text for clipboard copy, stripping
|
||||
// ANSI escape sequences. Grouped events carry their lines in fragments and
|
||||
// have an empty RawMessage, so they are expanded one line per fragment;
|
||||
// otherwise every grouped multi-line entry collapses to a single blank line on
|
||||
// copy.
|
||||
// ANSI escape sequences and the internal highlight markers. Grouped events
|
||||
// carry their lines in fragments and have an empty RawMessage, so they are
|
||||
// expanded one line per fragment; otherwise every grouped multi-line entry
|
||||
// collapses to a single blank line on copy.
|
||||
func (e *LogEvent) PlainText() string {
|
||||
if e.Type == LogTypeGroup {
|
||||
if fragments, ok := e.Message.([]LogFragment); ok {
|
||||
lines := make([]string, len(fragments))
|
||||
for i, fragment := range fragments {
|
||||
lines[i] = StripANSI(fragment.Message)
|
||||
lines[i] = StripHighlightMarkers(StripANSI(fragment.Message))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
return StripANSI(e.RawMessage)
|
||||
return StripHighlightMarkers(StripANSI(e.RawMessage))
|
||||
}
|
||||
|
||||
@@ -38,6 +38,25 @@ func TestLogEventPlainText(t *testing.T) {
|
||||
},
|
||||
expected: "red\nplain",
|
||||
},
|
||||
{
|
||||
// A regex filter marks search hits with U+E000/U+E001 before the
|
||||
// event is rendered; those internal markers must not leak into the
|
||||
// copied text.
|
||||
name: "grouped fragments strip search-highlight markers",
|
||||
event: &LogEvent{
|
||||
Type: LogTypeGroup,
|
||||
Message: []LogFragment{
|
||||
{Message: " at com.\ue000example\ue001.Main(Main.java:10)"},
|
||||
{Message: " at com.\ue000example\ue001.Foo(Foo.java:20)"},
|
||||
},
|
||||
},
|
||||
expected: " at com.example.Main(Main.java:10)\n at com.example.Foo(Foo.java:20)",
|
||||
},
|
||||
{
|
||||
name: "single event strips search-highlight markers",
|
||||
event: &LogEvent{Type: LogTypeSingle, RawMessage: "found \ue000error\ue001 here"},
|
||||
expected: "found error here",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -48,3 +67,23 @@ func TestLogEventPlainText(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripHighlightMarkers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"no markers", "plain text", "plain text"},
|
||||
{"search markers", "a \ue000b\ue001 c", "a b c"},
|
||||
{"url markers", "see \ue002http://example.com\ue003 now", "see http://example.com now"},
|
||||
{"mixed search and url markers", "\ue002http://x/\ue000hit\ue001\ue003", "http://x/hit"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := StripHighlightMarkers(tt.input); got != tt.expected {
|
||||
t.Errorf("StripHighlightMarkers(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,19 +99,19 @@ func (container Container) ToProto() pb.Container {
|
||||
}
|
||||
|
||||
return pb.Container{
|
||||
Id: container.ID,
|
||||
Name: container.Name,
|
||||
Image: container.Image,
|
||||
Created: timestamppb.New(container.Created),
|
||||
State: container.State,
|
||||
Health: container.Health,
|
||||
Host: container.Host,
|
||||
Tty: container.Tty,
|
||||
Labels: container.Labels,
|
||||
Group: container.Group,
|
||||
Started: timestamppb.New(container.StartedAt),
|
||||
Finished: timestamppb.New(container.FinishedAt),
|
||||
Stats: pbStats,
|
||||
Id: container.ID,
|
||||
Name: container.Name,
|
||||
Image: container.Image,
|
||||
Created: timestamppb.New(container.Created),
|
||||
State: container.State,
|
||||
Health: container.Health,
|
||||
Host: container.Host,
|
||||
Tty: container.Tty,
|
||||
Labels: container.Labels,
|
||||
Group: container.Group,
|
||||
Started: timestamppb.New(container.StartedAt),
|
||||
Finished: timestamppb.New(container.FinishedAt),
|
||||
Stats: pbStats,
|
||||
Command: container.Command,
|
||||
MemoryLimit: container.MemoryLimit,
|
||||
CpuLimit: container.CPULimit,
|
||||
@@ -176,20 +176,20 @@ func FromProto(c *pb.Container) Container {
|
||||
}
|
||||
|
||||
return Container{
|
||||
ID: c.Id,
|
||||
Name: c.Name,
|
||||
Image: c.Image,
|
||||
Labels: labels,
|
||||
Group: c.Group,
|
||||
Created: c.Created.AsTime(),
|
||||
State: c.State,
|
||||
Health: c.Health,
|
||||
Host: c.Host,
|
||||
Tty: c.Tty,
|
||||
Command: c.Command,
|
||||
StartedAt: c.Started.AsTime(),
|
||||
FinishedAt: c.Finished.AsTime(),
|
||||
Stats: utils.RingBufferFrom(300, stats),
|
||||
ID: c.Id,
|
||||
Name: c.Name,
|
||||
Image: c.Image,
|
||||
Labels: labels,
|
||||
Group: c.Group,
|
||||
Created: c.Created.AsTime(),
|
||||
State: c.State,
|
||||
Health: c.Health,
|
||||
Host: c.Host,
|
||||
Tty: c.Tty,
|
||||
Command: c.Command,
|
||||
StartedAt: c.Started.AsTime(),
|
||||
FinishedAt: c.Finished.AsTime(),
|
||||
Stats: utils.RingBufferFrom(300, stats),
|
||||
MemoryLimit: c.MemoryLimit,
|
||||
CPULimit: c.CpuLimit,
|
||||
FullyLoaded: c.FullyLoaded,
|
||||
@@ -233,12 +233,12 @@ func ParseContainerFilter(commaValues string) (ContainerLabels, error) {
|
||||
}
|
||||
|
||||
for val := range strings.SplitSeq(commaValues, ",") {
|
||||
pos := strings.Index(val, "=")
|
||||
if pos == -1 {
|
||||
before, after, ok := strings.Cut(val, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid filter: %s", filter)
|
||||
}
|
||||
key := val[:pos]
|
||||
val := val[pos+1:]
|
||||
key := before
|
||||
val := after
|
||||
filter[key] = append(filter[key], val)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func newVolumeMonitor(store *ContainerStore) *volumeMonitor {
|
||||
}
|
||||
|
||||
func (v *volumeMonitor) start(ctx context.Context) {
|
||||
for i := 0; i < volumeWorkerCount; i++ {
|
||||
for range volumeWorkerCount {
|
||||
go v.worker(ctx)
|
||||
}
|
||||
}
|
||||
|
||||