Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2eb5ec47 | |||
| 8358b98111 | |||
| af3aefe830 | |||
| ebc54a4481 | |||
| 8cf7ccd5ee | |||
| a9395e9f73 | |||
| b9df31356f | |||
| d2abd0e279 | |||
| 8a6ce3b21d | |||
| 239850c52d | |||
| 6b84105e79 | |||
| c159db5f0b | |||
| a18edf03ec | |||
| 18acf6722b | |||
| 5e0a4c43e6 | |||
| 48f04a0a7d | |||
| ae1753bcee | |||
| 3ae53dd719 | |||
| 3e0ed70362 | |||
| c6c64fdd86 | |||
| 616dd91483 | |||
| f952961eea | |||
| 3ca08f781b | |||
| 03011eea97 | |||
| 3783125b6a | |||
| fe6549e5f5 | |||
| cedb384dc4 | |||
| 163452a398 | |||
| f348b57721 | |||
| df5a60479c | |||
| 9c665d42a9 | |||
| 49f625a1db | |||
| af38e3a0c0 | |||
| 104749063d | |||
| d7b896e3d8 | |||
| 5693bcc4e4 | |||
| de70cbe389 | |||
| 73099aa67a | |||
| 9266f89776 | |||
| f88d7a1066 | |||
| f3e61606bd | |||
| 95794779d6 | |||
| e3f4566f6f | |||
| b86831d2ba | |||
| 4917984385 | |||
| c3b1c76151 | |||
| 7897a1555e | |||
| 523ac9f35e | |||
| 6e65b33161 | |||
| be2a9e0047 | |||
| fad2ea7763 | |||
| 4e7b6eea7b | |||
| a0704d5c7b | |||
| 63fa7c9e04 | |||
| 237e916390 | |||
| 6a16e35eb1 | |||
| 15b6abf843 | |||
| 540ace4e84 | |||
| c0a38587e2 |
@@ -13,15 +13,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm --version
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- run: corepack enable
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile --prefer-offline
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
@@ -31,7 +34,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: 1.26.5
|
go-version: 1.26.5
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -51,15 +54,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm --version
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- run: corepack enable
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -95,12 +101,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4.4.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v4.4.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -127,6 +133,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v7.3.0
|
uses: docker/build-push-action@v7.3.0
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
|
sbom: true
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -145,10 +152,8 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
name: Install pnpm
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
- name: Release to Github
|
- name: Release to Github
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4.4.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v4.4.0
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||||
- uses: pnpm/action-setup@v6
|
- name: Install Node
|
||||||
- name: Setup Node
|
uses: actions/setup-node@v7
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm --version
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: latest
|
||||||
cache: pnpm # or pnpm / yarn
|
cache: pnpm # or pnpm / yarn
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v6
|
uses: actions/configure-pages@v6
|
||||||
|
|||||||
@@ -16,17 +16,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -38,13 +37,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
name: Install Node
|
||||||
|
with:
|
||||||
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -56,7 +58,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.5"
|
go-version: "1.26.5"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -79,7 +81,7 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.5"
|
go-version: "1.26.5"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -95,17 +97,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/setup-node@v7
|
||||||
name: Install pnpm
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
name: Install Node
|
name: Install Node
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
|
- run: npm install --global corepack@latest
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: latest
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
|
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||||
const EffectScope: typeof import('vue').EffectScope
|
const EffectScope: typeof import('vue').EffectScope
|
||||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||||
|
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
|
||||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||||
const allLevels: typeof import('./composable/logContext').allLevels
|
const allLevels: typeof import('./composable/logContext').allLevels
|
||||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||||
@@ -17,6 +19,7 @@ declare global {
|
|||||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||||
|
const colorize: typeof import('./utils/index').colorize
|
||||||
const compact: typeof import('./stores/settings').compact
|
const compact: typeof import('./stores/settings').compact
|
||||||
const computed: typeof import('vue').computed
|
const computed: typeof import('vue').computed
|
||||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||||
@@ -216,6 +219,7 @@ declare global {
|
|||||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||||
|
const useCommands: typeof import('./composable/commands').useCommands
|
||||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||||
@@ -420,6 +424,9 @@ declare global {
|
|||||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||||
import('./composable/cloudLogSearch')
|
import('./composable/cloudLogSearch')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
export type { CommandSection, Command } from './composable/commands'
|
||||||
|
import('./composable/commands')
|
||||||
|
// @ts-ignore
|
||||||
export type { DrawerWidth } from './composable/drawer'
|
export type { DrawerWidth } from './composable/drawer'
|
||||||
import('./composable/drawer')
|
import('./composable/drawer')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -461,6 +468,7 @@ declare module 'vue' {
|
|||||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
||||||
|
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
|
||||||
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||||
@@ -655,6 +663,7 @@ declare module 'vue' {
|
|||||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||||
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
||||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||||
|
readonly useCommands: UnwrapRef<typeof import('./composable/commands')['useCommands']>
|
||||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { build } from "vite";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("production build", () => {
|
||||||
|
it("emits font URLs relative to the generated stylesheet", async () => {
|
||||||
|
const result = await build({
|
||||||
|
configFile: "vite.config.ts",
|
||||||
|
logLevel: "silent",
|
||||||
|
build: { write: false },
|
||||||
|
});
|
||||||
|
const outputs = Array.isArray(result) ? result : [result];
|
||||||
|
const stylesheet = outputs
|
||||||
|
.flatMap((output) => ("output" in output ? output.output : []))
|
||||||
|
.find((item) => item.type === "asset" && item.fileName.endsWith(".css"));
|
||||||
|
|
||||||
|
expect(stylesheet).toBeDefined();
|
||||||
|
if (!stylesheet || stylesheet.type !== "asset") {
|
||||||
|
throw new Error("Production build did not emit a stylesheet");
|
||||||
|
}
|
||||||
|
const css = String(stylesheet.source);
|
||||||
|
expect(css).toMatch(/url\(\.\/jetbrains-mono/);
|
||||||
|
expect(css).not.toMatch(/url\(\/assets\/jetbrains-mono/);
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
@@ -105,6 +105,8 @@ declare module 'vue' {
|
|||||||
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
||||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||||
|
'Mdi:arrowCollapse': typeof import('~icons/mdi/arrow-collapse')['default']
|
||||||
|
'Mdi:arrowExpand': typeof import('~icons/mdi/arrow-expand')['default']
|
||||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||||
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
||||||
@@ -169,6 +171,7 @@ declare module 'vue' {
|
|||||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||||
'Ph:database': typeof import('~icons/ph/database')['default']
|
'Ph:database': typeof import('~icons/ph/database')['default']
|
||||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||||
|
'Ph:downloadSimple': typeof import('~icons/ph/download-simple')['default']
|
||||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const clear = defineEmit();
|
|||||||
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { copy, copied, isSupported } = useClipboard();
|
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showToast, removeToast } = useToast();
|
const { showToast, removeToast } = useToast();
|
||||||
|
|
||||||
@@ -366,10 +366,25 @@ a {
|
|||||||
@apply whitespace-nowrap;
|
@apply whitespace-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* daisyUI's .menu is width: fit-content, so nested submenus (Streams, Levels)
|
||||||
|
* shrink to their content and the hover highlight stops short. Stretch them to
|
||||||
|
* fill the dropdown so the row highlight spans the full width. */
|
||||||
.menu li ul {
|
.menu li ul {
|
||||||
margin-inline-start: 0;
|
margin-inline-start: 0;
|
||||||
|
width: 100%;
|
||||||
&:before {
|
&:before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep the solid level colors, but use white labels in the light theme so the
|
||||||
|
* text reads against the saturated chip backgrounds. warn is a light orange,
|
||||||
|
* where dark text has better contrast than white, so it keeps the default. */
|
||||||
|
[data-theme="light"] .badge[data-level="info"],
|
||||||
|
[data-theme="light"] .badge[data-level="debug"],
|
||||||
|
[data-theme="light"] .badge[data-level="trace"],
|
||||||
|
[data-theme="light"] .badge[data-level="error"],
|
||||||
|
[data-theme="light"] .badge[data-level="fatal"] {
|
||||||
|
color: oklch(100% 0 0) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,8 +46,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||||
<VolumeWarning :container="container" />
|
<VolumeWarning :container="container" />
|
||||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
<Tag
|
||||||
{{ container.image.replace(/@sha.*/, "") }}
|
class="group hidden! cursor-pointer items-center gap-1.5 pr-1! font-mono @md:inline-flex!"
|
||||||
|
size="small"
|
||||||
|
role="button"
|
||||||
|
:title="$t('toolbar.copy-image')"
|
||||||
|
:aria-label="$t('toolbar.copy-image')"
|
||||||
|
@click="copyImage"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ imageTag }}</span>
|
||||||
|
<span
|
||||||
|
class="bg-base-content/10 text-base-content/40 group-hover:text-base-content/70 flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors"
|
||||||
|
>
|
||||||
|
<mdi:content-copy class="size-3" />
|
||||||
|
</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,6 +68,21 @@
|
|||||||
import { Container } from "@/models/Container";
|
import { Container } from "@/models/Container";
|
||||||
|
|
||||||
const { container } = defineProps<{ container: Container }>();
|
const { container } = defineProps<{ container: Container }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
|
||||||
|
|
||||||
|
async function copyImage() {
|
||||||
|
if (!isSupported.value) return;
|
||||||
|
await copy(imageTag.value);
|
||||||
|
if (copied.value) {
|
||||||
|
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pinned = computed({
|
const pinned = computed({
|
||||||
get: () => pinnedContainers.value.has(container.name),
|
get: () => pinnedContainers.value.has(container.name),
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
|
|||||||
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
||||||
|
|
||||||
import { Container } from "@/models/Container";
|
import { Container } from "@/models/Container";
|
||||||
|
import { lightTheme } from "@/stores/settings";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
@@ -16,7 +17,7 @@ vi.mock("vue-router");
|
|||||||
|
|
||||||
vi.mock("@/stores/config", () => ({
|
vi.mock("@/stores/config", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
|
||||||
withBase: (path: string) => path,
|
withBase: (path: string) => path,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -119,4 +120,28 @@ describe("<FuzzySearchModal />", () => {
|
|||||||
await wrapper.find("input").trigger("keydown.enter");
|
await wrapper.find("input").trigger("keydown.enter");
|
||||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("matches commands by keyword", async () => {
|
||||||
|
const wrapper = createFuzzySearchModal();
|
||||||
|
await wrapper.find("input").setValue("theme");
|
||||||
|
const items = wrapper.findAll("li").map((li) => li.text());
|
||||||
|
expect(items).toContain("command-palette.theme-dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("theme commands set the theme explicitly", async () => {
|
||||||
|
lightTheme.value = "auto";
|
||||||
|
const wrapper = createFuzzySearchModal();
|
||||||
|
|
||||||
|
await wrapper.find("input").setValue("dark theme");
|
||||||
|
await wrapper.find("input").trigger("keydown.enter");
|
||||||
|
expect(lightTheme.value).toBe("dark");
|
||||||
|
|
||||||
|
await wrapper.find("input").setValue("light theme");
|
||||||
|
await wrapper.find("input").trigger("keydown.enter");
|
||||||
|
expect(lightTheme.value).toBe("light");
|
||||||
|
|
||||||
|
await wrapper.find("input").setValue("system theme");
|
||||||
|
await wrapper.find("input").trigger("keydown.enter");
|
||||||
|
expect(lightTheme.value).toBe("auto");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
||||||
ref="input"
|
ref="input"
|
||||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, totalCount - 1)"
|
||||||
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||||
@keydown.enter.exact="onEnter"
|
@keydown.enter.exact="onEnter"
|
||||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
@keydown.alt.enter.exact.prevent="onPin"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
:placeholder="placeholderCopy"
|
:placeholder="placeholderCopy"
|
||||||
/>
|
/>
|
||||||
@@ -29,51 +29,80 @@
|
|||||||
|
|
||||||
<!-- Body: results + log search CTA. Only renders when there is something
|
<!-- Body: results + log search CTA. Only renders when there is something
|
||||||
to show — keeps the empty modal compact. -->
|
to show — keeps the empty modal compact. -->
|
||||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
<div v-if="totalCount || logSearchVisible" class="border-base-content/10 border-t">
|
||||||
<!-- Containers section -->
|
<!-- Scroll container spans both sections so the flat Commands + Containers
|
||||||
<template v-if="results.length">
|
list scrolls as one, matching the unified selection index. -->
|
||||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
<div class="max-h-[50vh] overflow-y-auto overscroll-contain">
|
||||||
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
|
<!-- Commands section -->
|
||||||
</div>
|
<template v-if="commandEntries.length">
|
||||||
<ul class="pb-1">
|
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||||
<li v-for="(result, index) in data" ref="listItems">
|
{{ $t("command-palette.section-commands") }} · {{ commandEntries.length }}
|
||||||
<a
|
</div>
|
||||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
<ul class="pb-1">
|
||||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
<li v-for="(command, index) in commandEntries" :ref="(el) => setItemRef(el, index)">
|
||||||
@click.prevent="selected(result.item)"
|
<a
|
||||||
>
|
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||||
<template v-if="result.item.type === 'container'">
|
@click.prevent="runCommand(command)"
|
||||||
<octicon:container-24 class="size-4" />
|
|
||||||
</template>
|
|
||||||
<template v-else-if="result.item.type === 'service'">
|
|
||||||
<ph:stack-simple class="size-4" />
|
|
||||||
</template>
|
|
||||||
<template v-else-if="result.item.type === 'stack'">
|
|
||||||
<ph:stack class="size-4" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1 truncate text-sm">
|
|
||||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
|
||||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
|
||||||
<span class="text-base-content/30"> / </span>
|
|
||||||
</template>
|
|
||||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
|
||||||
</div>
|
|
||||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
|
||||||
<span
|
|
||||||
@click.stop.prevent="addColumn(result.item)"
|
|
||||||
:title="$t('tooltip.pin-column')"
|
|
||||||
class="text-base-content/40 hover:text-secondary"
|
|
||||||
>
|
>
|
||||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
|
<component :is="command.icon" class="text-base-content/60 size-4 shrink-0" />
|
||||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
<span class="min-w-0 flex-1 truncate text-sm">{{ command.title }}</span>
|
||||||
</span>
|
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="text-base-content/40 size-4" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Containers section -->
|
||||||
|
<template v-if="containerEntries.length">
|
||||||
|
<div
|
||||||
|
class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase"
|
||||||
|
:class="{ 'border-base-content/10 mt-1 border-t': commandEntries.length }"
|
||||||
|
>
|
||||||
|
{{ $t("cloud-search.containers-section") }} · {{ containerEntries.length }}
|
||||||
|
</div>
|
||||||
|
<ul class="pb-1">
|
||||||
|
<li
|
||||||
|
v-for="(result, index) in containerEntries"
|
||||||
|
:ref="(el) => setItemRef(el, commandEntries.length + index)"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||||
|
:class="{ 'bg-base-content/10': commandEntries.length + index === selectedIndex }"
|
||||||
|
@click.prevent="selected(result.item)"
|
||||||
|
>
|
||||||
|
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||||
|
<template v-if="result.item.type === 'container'">
|
||||||
|
<octicon:container-24 class="size-4" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result.item.type === 'service'">
|
||||||
|
<ph:stack-simple class="size-4" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result.item.type === 'stack'">
|
||||||
|
<ph:stack class="size-4" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1 truncate text-sm">
|
||||||
|
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||||
|
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||||
|
<span class="text-base-content/30"> / </span>
|
||||||
|
</template>
|
||||||
|
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||||
|
</div>
|
||||||
|
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||||
|
<span
|
||||||
|
@click.stop.prevent="addColumn(result.item)"
|
||||||
|
:title="$t('tooltip.pin-column')"
|
||||||
|
class="text-base-content/40 hover:text-secondary"
|
||||||
|
>
|
||||||
|
<ic:sharp-keyboard-return v-if="commandEntries.length + index === selectedIndex" class="size-4" />
|
||||||
|
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<!-- Log search CTA -->
|
<!-- Log search CTA -->
|
||||||
<div
|
<div
|
||||||
v-if="logSearchVisible"
|
v-if="logSearchVisible"
|
||||||
@@ -130,7 +159,7 @@
|
|||||||
<div
|
<div
|
||||||
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
|
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
|
||||||
>
|
>
|
||||||
<span v-if="results.length" class="flex items-center gap-1.5">
|
<span v-if="totalCount" class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
||||||
@@ -164,6 +193,7 @@ import { useFuse } from "@vueuse/integrations/useFuse";
|
|||||||
import { type FuseResult } from "fuse.js";
|
import { type FuseResult } from "fuse.js";
|
||||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||||
|
import { useCommands, type Command } from "@/composable/commands";
|
||||||
|
|
||||||
const close = defineEmit();
|
const close = defineEmit();
|
||||||
|
|
||||||
@@ -176,9 +206,15 @@ const route = useRoute();
|
|||||||
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
||||||
const query = ref(initialQuery);
|
const query = ref(initialQuery);
|
||||||
const input = ref<HTMLInputElement>();
|
const input = ref<HTMLInputElement>();
|
||||||
const listItems = ref<HTMLInputElement[]>();
|
const listItems = ref<(Element | null)[]>([]);
|
||||||
const selectedIndex = ref(0);
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
|
// Function ref into a single flat array so Commands and Containers share one
|
||||||
|
// selection index for arrow-key navigation and scroll-into-view.
|
||||||
|
function setItemRef(el: any, index: number) {
|
||||||
|
listItems.value[index] = (el?.$el ?? el) as Element | null;
|
||||||
|
}
|
||||||
|
|
||||||
const containerStore = useContainerStore();
|
const containerStore = useContainerStore();
|
||||||
const pinnedStore = usePinnedLogsStore();
|
const pinnedStore = usePinnedLogsStore();
|
||||||
const { visibleContainers } = storeToRefs(containerStore);
|
const { visibleContainers } = storeToRefs(containerStore);
|
||||||
@@ -268,6 +304,20 @@ const { results: fuseResults } = useFuse(query, list, {
|
|||||||
|
|
||||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
const results = computed(() => (query.value ? fuseResults.value : []));
|
||||||
|
|
||||||
|
// Commands palette. Fuzzy-matched against title/keywords while typing; the
|
||||||
|
// context commands (container actions) show up front on an empty query.
|
||||||
|
const { commands, contextCommands } = useCommands();
|
||||||
|
const { results: commandFuseResults } = useFuse(query, commands, {
|
||||||
|
fuseOptions: {
|
||||||
|
keys: ["title", "keywords"],
|
||||||
|
useExtendedSearch: true,
|
||||||
|
threshold: 0.3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const commandEntries = computed<Command[]>(() =>
|
||||||
|
query.value ? commandFuseResults.value.map((r) => r.item) : contextCommands.value,
|
||||||
|
);
|
||||||
|
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||||
if (a.score === b.score) {
|
if (a.score === b.score) {
|
||||||
@@ -284,14 +334,26 @@ const data = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(query, (data) => {
|
// Container hits, mirrors the previously named `data` list for the template.
|
||||||
if (data.length > 0) {
|
const containerEntries = computed(() => data.value);
|
||||||
selectedIndex.value = 0;
|
const totalCount = computed(() => commandEntries.value.length + containerEntries.value.length);
|
||||||
|
|
||||||
|
// Reset to the top only when the user types. Live SSE container add/remove
|
||||||
|
// changes totalCount too, and resetting on that would snap the selection back
|
||||||
|
// to 0 while the palette is open.
|
||||||
|
watch(query, () => {
|
||||||
|
selectedIndex.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the selection in bounds when the result count shrinks underneath it.
|
||||||
|
watch(totalCount, (count) => {
|
||||||
|
if (selectedIndex.value > count - 1) {
|
||||||
|
selectedIndex.value = Math.max(count - 1, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(selectedIndex, () => {
|
watch(selectedIndex, () => {
|
||||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
listItems.value?.[selectedIndex.value]?.scrollIntoView({ block: "nearest" });
|
||||||
});
|
});
|
||||||
|
|
||||||
function selected(item: Item) {
|
function selected(item: Item) {
|
||||||
@@ -305,17 +367,35 @@ function selected(item: Item) {
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCommand(command: Command) {
|
||||||
|
close();
|
||||||
|
await command.perform();
|
||||||
|
}
|
||||||
|
|
||||||
function onEnter() {
|
function onEnter() {
|
||||||
// Plain Enter prefers a container match if one is selected. With no
|
// Commands come first in the flat list, then containers. With nothing
|
||||||
// container matches (cloud-only query like "OOM"), fall back to log search
|
// selectable (cloud-only query like "OOM"), fall back to log search so the
|
||||||
// so the user isn't stuck on a popup that does nothing.
|
// user isn't stuck on a popup that does nothing.
|
||||||
if (data.value.length > 0) {
|
const commandCount = commandEntries.value.length;
|
||||||
selected(data.value[selectedIndex.value].item);
|
if (selectedIndex.value < commandCount) {
|
||||||
|
runCommand(commandEntries.value[selectedIndex.value]);
|
||||||
|
} else if (containerEntries.value.length > 0) {
|
||||||
|
selected(containerEntries.value[selectedIndex.value - commandCount].item);
|
||||||
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
||||||
runLogSearch();
|
runLogSearch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPin() {
|
||||||
|
// Alt+Enter pins a container column. Only meaningful when a container row is
|
||||||
|
// selected, not a command.
|
||||||
|
const commandCount = commandEntries.value.length;
|
||||||
|
if (selectedIndex.value >= commandCount) {
|
||||||
|
const entry = containerEntries.value[selectedIndex.value - commandCount];
|
||||||
|
if (entry?.item.type === "container") addColumn(entry.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runLogSearch() {
|
function runLogSearch() {
|
||||||
if (!cloudSearch.available.value) return;
|
if (!cloudSearch.available.value) return;
|
||||||
const q = query.value.trim();
|
const q = query.value.trim();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<mdi:cog class="size-6" />
|
<mdi:cog class="size-6" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<dropdown class="dropdown-end" v-if="config.user">
|
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<template v-if="config.disableAvatars || !config.user.email">
|
<template v-if="config.disableAvatars || !config.user.email">
|
||||||
<material-symbols:person class="size-6" />
|
<material-symbols:person class="size-6" />
|
||||||
|
|||||||
@@ -14,13 +14,6 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
||||||
import AnsiConvertor from "ansi-to-html";
|
|
||||||
|
|
||||||
const ansiConvertor = new AnsiConvertor({
|
|
||||||
escapeXML: false,
|
|
||||||
fg: "var(--color-base-content)",
|
|
||||||
bg: "var(--color-base-100)",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { logEntry } = defineProps<{
|
const { logEntry } = defineProps<{
|
||||||
logEntry: GroupedLogEntry;
|
logEntry: GroupedLogEntry;
|
||||||
@@ -32,6 +25,4 @@ const getPosition = (index: number): Position => {
|
|||||||
if (index === len - 1) return "end";
|
if (index === len - 1) return "end";
|
||||||
return "middle";
|
return "middle";
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@mouseenter="checkDropdownPosition"
|
@mouseenter="checkDropdownPosition"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isSearching"
|
v-if="isFiltered"
|
||||||
@click="resetSearch()"
|
@click="resetSearch()"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
|
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
||||||
@click="hideMenu"
|
@click="hideMenu"
|
||||||
>
|
>
|
||||||
<li v-if="isSearching">
|
<li v-if="isFiltered">
|
||||||
<router-link
|
<router-link
|
||||||
@click="resetSearch()"
|
@click="resetSearch()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -99,8 +99,13 @@ const { showToast } = useToast();
|
|||||||
const showDrawer = useDrawer();
|
const showDrawer = useDrawer();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSearching, resetSearch } = useSearchFilter();
|
const { isSearching, resetSearch } = useSearchFilter();
|
||||||
|
const { levels } = useLoggingContext();
|
||||||
|
|
||||||
const { copy, isSupported, copied } = useClipboard();
|
// Show "see in context" whenever the stream is narrowed, either by a text search
|
||||||
|
// or by a log-level filter, so the entry can be inspected in the full log stream.
|
||||||
|
const isFiltered = computed(() => isSearching.value || allLevels.some((level) => !levels.value.has(level)));
|
||||||
|
|
||||||
|
const { copy, isSupported, copied } = useClipboard({ legacy: true });
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
async function copyLogMessage() {
|
async function copyLogMessage() {
|
||||||
|
|||||||
@@ -48,6 +48,25 @@
|
|||||||
}}</template>
|
}}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown dropdown-end shrink-0" v-if="canExport">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-xs btn-ghost cursor-pointer gap-1">
|
||||||
|
<ph:download-simple class="size-4" />
|
||||||
|
{{ $t("analytics.export") }}
|
||||||
|
</div>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-30 w-44 p-2 shadow-sm">
|
||||||
|
<li>
|
||||||
|
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('csv')">{{
|
||||||
|
$t("analytics.export_csv")
|
||||||
|
}}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('json')">{{
|
||||||
|
$t("analytics.export_json")
|
||||||
|
}}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -238,5 +257,54 @@ whenever(evaluating, () => {
|
|||||||
const page = computed(() =>
|
const page = computed(() =>
|
||||||
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
||||||
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
||||||
|
|
||||||
|
const canExport = computed(() => state.value === "ready" && !evaluating.value && results.value.numRows > 0);
|
||||||
|
|
||||||
|
function stringify(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
if (typeof value === "bigint") return value.toString();
|
||||||
|
if (typeof value === "object") return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCSV(table: Table<Record<string, any>>, columns: string[]): string {
|
||||||
|
const escape = (value: string) => (/[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
|
||||||
|
const lines = [columns.map(escape).join(",")];
|
||||||
|
for (const row of table) {
|
||||||
|
lines.push(columns.map((column) => escape(stringify((row as Record<string, any>)[column]))).join(","));
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJSON(table: Table<Record<string, any>>, columns: string[]): string {
|
||||||
|
const rows = [];
|
||||||
|
for (const row of table) {
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
const value = (row as Record<string, any>)[column];
|
||||||
|
record[column] = typeof value === "bigint" ? value.toString() : value;
|
||||||
|
}
|
||||||
|
rows.push(record);
|
||||||
|
}
|
||||||
|
return JSON.stringify(rows, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportResults(format: "csv" | "json") {
|
||||||
|
const table = results.value as unknown as Table<Record<string, any>>;
|
||||||
|
if (table.numRows === 0) return;
|
||||||
|
|
||||||
|
const columns = Object.keys(table.get(0) as Record<string, any>);
|
||||||
|
const content = format === "csv" ? toCSV(table, columns) : toJSON(table, columns);
|
||||||
|
const type = format === "csv" ? "text/csv;charset=utf-8" : "application/json";
|
||||||
|
const name = container.name.replace(/[^\w.-]+/g, "-");
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${name}-query.${format}`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -9,17 +9,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||||
import AnsiConvertor from "ansi-to-html";
|
|
||||||
|
|
||||||
const ansiConvertor = new AnsiConvertor({
|
|
||||||
escapeXML: false,
|
|
||||||
fg: "var(--color-base-content)",
|
|
||||||
bg: "var(--color-base-100)",
|
|
||||||
});
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
logEntry: SimpleLogEntry;
|
logEntry: SimpleLogEntry;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
|||||||
</li>
|
</li>
|
||||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
</svg></a>
|
||||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
|
||||||
</svg></button>
|
|
||||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||||
<!--v-if-->
|
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||||
|
</svg> action.see-in-context</a></li>
|
||||||
|
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||||
</svg> action.copy-log</a></li>
|
</svg> action.copy-log</a></li>
|
||||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||||
</svg> action.copy-link</a></li>
|
</svg> action.copy-link</a></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
@@ -48,17 +48,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
|||||||
</li>
|
</li>
|
||||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
</svg></a>
|
||||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
|
||||||
</svg></button>
|
|
||||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||||
<!--v-if-->
|
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||||
|
</svg> action.see-in-context</a></li>
|
||||||
|
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||||
</svg> action.copy-log</a></li>
|
</svg> action.copy-log</a></li>
|
||||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||||
</svg> action.copy-link</a></li>
|
</svg> action.copy-link</a></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
@@ -89,17 +89,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
|||||||
</li>
|
</li>
|
||||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
</svg></a>
|
||||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
|
||||||
</svg></button>
|
|
||||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||||
<!--v-if-->
|
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||||
|
</svg> action.see-in-context</a></li>
|
||||||
|
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||||
</svg> action.copy-log</a></li>
|
</svg> action.copy-log</a></li>
|
||||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||||
</svg> action.copy-link</a></li>
|
</svg> action.copy-link</a></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||||
<header
|
<header
|
||||||
v-if="$slots.header"
|
v-if="$slots.header"
|
||||||
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
data-testid="scrollable-header"
|
||||||
|
class="border-base-content/10 bg-base-200 sticky top-[var(--mobile-nav-height)] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||||
>
|
>
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
|
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
|
||||||
<div class="p-2">
|
<div class="px-4 py-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<router-link :to="{ name: '/' }">
|
<router-link :to="{ name: '/' }">
|
||||||
<Logo class="h-10" />
|
<Logo class="h-10" />
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
||||||
<div class="modal-box" :width="width">
|
<div class="modal-box" :width="maximized ? 'full' : width">
|
||||||
<div class="pt-safe relative">
|
<div class="pt-safe relative">
|
||||||
<form method="dialog" class="absolute right-0">
|
<div class="absolute right-0 flex items-center gap-3">
|
||||||
<button v-if="isMobile">
|
<button
|
||||||
<mdi:close />
|
v-if="!isMobile"
|
||||||
|
class="hover:text-base-content/60 cursor-pointer outline-hidden"
|
||||||
|
type="button"
|
||||||
|
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||||
|
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||||
|
@click="maximized = !maximized"
|
||||||
|
>
|
||||||
|
<mdi:arrow-collapse v-if="maximized" />
|
||||||
|
<mdi:arrow-expand v-else />
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="swap hover:swap-active outline-hidden">
|
<form method="dialog">
|
||||||
<mdi:keyboard-esc class="swap-off" />
|
<button v-if="isMobile" class="cursor-pointer">
|
||||||
<mdi:close class="swap-on" />
|
<mdi:close />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<button v-else class="swap hover:swap-active cursor-pointer outline-hidden">
|
||||||
|
<mdi:keyboard-esc class="swap-off" />
|
||||||
|
<mdi:close class="swap-on" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<slot v-if="open" :close="close"></slot>
|
<slot v-if="open" :close="close"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,6 +37,7 @@ import { type DrawerWidth } from "@/composable/drawer";
|
|||||||
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
|
const maximized = ref(false);
|
||||||
const { width } = defineProps<{
|
const { width } = defineProps<{
|
||||||
width: DrawerWidth;
|
width: DrawerWidth;
|
||||||
}>();
|
}>();
|
||||||
@@ -35,6 +49,7 @@ function close() {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
open: () => {
|
open: () => {
|
||||||
open.value = true;
|
open.value = true;
|
||||||
|
maximized.value = false;
|
||||||
panel.value?.showModal();
|
panel.value?.showModal();
|
||||||
},
|
},
|
||||||
close,
|
close,
|
||||||
@@ -55,6 +70,10 @@ useEventListener(panel, "close", () => (open.value = false));
|
|||||||
&[width="lg"] {
|
&[width="lg"] {
|
||||||
@apply max-w-5xl;
|
@apply max-w-5xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[width="full"] {
|
||||||
|
@apply w-full max-w-full;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-right[open] .modal-box {
|
.modal-right[open] .modal-box {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { Component } from "vue";
|
||||||
|
import { Container } from "@/models/Container";
|
||||||
|
import { useContainerActions } from "@/composable/containerActions";
|
||||||
|
import config from "@/stores/config";
|
||||||
|
import {
|
||||||
|
lightTheme,
|
||||||
|
compact,
|
||||||
|
showTimestamp,
|
||||||
|
softWrap,
|
||||||
|
showAllContainers,
|
||||||
|
showStd,
|
||||||
|
smallerScrollbars,
|
||||||
|
} from "@/stores/settings";
|
||||||
|
|
||||||
|
import mdiThemeLightDark from "~icons/mdi/theme-light-dark";
|
||||||
|
import mdiWhiteBalanceSunny from "~icons/mdi/white-balance-sunny";
|
||||||
|
import mdiWeatherNight from "~icons/mdi/weather-night";
|
||||||
|
import mdiFormatLineSpacing from "~icons/mdi/format-line-spacing";
|
||||||
|
import mdiClockOutline from "~icons/mdi/clock-outline";
|
||||||
|
import mdiWrap from "~icons/mdi/wrap";
|
||||||
|
import mdiEyeOutline from "~icons/mdi/eye-outline";
|
||||||
|
import mdiFormatListBulleted from "~icons/mdi/format-list-bulleted";
|
||||||
|
import mdiUnfoldMoreHorizontal from "~icons/mdi/unfold-more-horizontal";
|
||||||
|
import mdiCogOutline from "~icons/mdi/cog-outline";
|
||||||
|
import carbonRestart from "~icons/carbon/restart";
|
||||||
|
import mdiStop from "~icons/mdi/stop";
|
||||||
|
import mdiPlay from "~icons/mdi/play";
|
||||||
|
import mdiDownload from "~icons/mdi/download";
|
||||||
|
|
||||||
|
export type CommandSection = "container" | "settings" | "navigation";
|
||||||
|
|
||||||
|
export type Command = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
section: CommandSection;
|
||||||
|
icon: Component;
|
||||||
|
keywords?: string;
|
||||||
|
perform: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Central registry for the Cmd+K command palette. Commands are recomputed on
|
||||||
|
// every access so context-sensitive entries (container actions, current
|
||||||
|
// toggle labels) stay in sync with the route and settings.
|
||||||
|
export function useCommands() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const containerStore = useContainerStore();
|
||||||
|
|
||||||
|
const currentId = computed(() =>
|
||||||
|
route?.name === "/container/[id]" && typeof route.params.id === "string" ? route.params.id : "",
|
||||||
|
);
|
||||||
|
// Null-safe: containerStore.currentContainer is a stubbed action under
|
||||||
|
// @pinia/testing, so guard against it being absent.
|
||||||
|
const currentContainerRef = containerStore.currentContainer?.(currentId);
|
||||||
|
const currentContainer = computed(() => currentContainerRef?.value as Container | undefined);
|
||||||
|
|
||||||
|
// Bound to the current container. The cast is safe because the action
|
||||||
|
// handlers only read container.value when invoked, and container commands are
|
||||||
|
// only pushed into the list when currentContainer is truthy — so the handlers
|
||||||
|
// never run against an undefined container.
|
||||||
|
const { start, stop, restart, update } = useContainerActions(currentContainer as Ref<Container>);
|
||||||
|
|
||||||
|
const commands = computed<Command[]>(() => {
|
||||||
|
const list: Command[] = [];
|
||||||
|
|
||||||
|
const container = currentContainer.value;
|
||||||
|
if (container && config.enableActions) {
|
||||||
|
const name = container.name;
|
||||||
|
list.push({
|
||||||
|
id: "container.restart",
|
||||||
|
section: "container",
|
||||||
|
icon: carbonRestart,
|
||||||
|
title: t("command-palette.restart-container", { name }),
|
||||||
|
keywords: "restart reboot",
|
||||||
|
perform: restart,
|
||||||
|
});
|
||||||
|
if (container.state === "running") {
|
||||||
|
list.push({
|
||||||
|
id: "container.stop",
|
||||||
|
section: "container",
|
||||||
|
icon: mdiStop,
|
||||||
|
title: t("command-palette.stop-container", { name }),
|
||||||
|
keywords: "stop kill halt",
|
||||||
|
perform: stop,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
list.push({
|
||||||
|
id: "container.start",
|
||||||
|
section: "container",
|
||||||
|
icon: mdiPlay,
|
||||||
|
title: t("command-palette.start-container", { name }),
|
||||||
|
keywords: "start run",
|
||||||
|
perform: start,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.push({
|
||||||
|
id: "container.update",
|
||||||
|
section: "container",
|
||||||
|
icon: mdiDownload,
|
||||||
|
title: t("command-palette.update-container", { name }),
|
||||||
|
keywords: "update pull recreate upgrade",
|
||||||
|
perform: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.push(
|
||||||
|
// lightTheme is tri-state, so expose each value as its own command rather
|
||||||
|
// than a single toggle — that keeps "auto" (follow OS) reachable and makes
|
||||||
|
// the target theme explicit instead of depending on the current state.
|
||||||
|
{
|
||||||
|
id: "settings.theme-auto",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiThemeLightDark,
|
||||||
|
title: t("command-palette.theme-auto"),
|
||||||
|
keywords: "theme auto system color mode appearance",
|
||||||
|
perform: () => (lightTheme.value = "auto"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.theme-light",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiWhiteBalanceSunny,
|
||||||
|
title: t("command-palette.theme-light"),
|
||||||
|
keywords: "theme light color mode appearance",
|
||||||
|
perform: () => (lightTheme.value = "light"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.theme-dark",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiWeatherNight,
|
||||||
|
title: t("command-palette.theme-dark"),
|
||||||
|
keywords: "theme dark color mode appearance",
|
||||||
|
perform: () => (lightTheme.value = "dark"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.toggle-compact",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiFormatLineSpacing,
|
||||||
|
title: t("command-palette.toggle-compact"),
|
||||||
|
keywords: "compact density spacing",
|
||||||
|
perform: () => (compact.value = !compact.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.toggle-timestamps",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiClockOutline,
|
||||||
|
title: t("command-palette.toggle-timestamps"),
|
||||||
|
keywords: "timestamp time date",
|
||||||
|
perform: () => (showTimestamp.value = !showTimestamp.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.toggle-soft-wrap",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiWrap,
|
||||||
|
title: t("command-palette.toggle-soft-wrap"),
|
||||||
|
keywords: "wrap soft line",
|
||||||
|
perform: () => (softWrap.value = !softWrap.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.toggle-stopped",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiEyeOutline,
|
||||||
|
title: t("command-palette.toggle-stopped"),
|
||||||
|
keywords: "stopped hidden all containers exited",
|
||||||
|
perform: () => (showAllContainers.value = !showAllContainers.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.toggle-std",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiFormatListBulleted,
|
||||||
|
title: t("command-palette.toggle-std"),
|
||||||
|
keywords: "stdout stderr std labels stream",
|
||||||
|
perform: () => (showStd.value = !showStd.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings.toggle-scrollbars",
|
||||||
|
section: "settings",
|
||||||
|
icon: mdiUnfoldMoreHorizontal,
|
||||||
|
title: t("command-palette.toggle-scrollbars"),
|
||||||
|
keywords: "scrollbar smaller thin",
|
||||||
|
perform: () => (smallerScrollbars.value = !smallerScrollbars.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "navigation.settings",
|
||||||
|
section: "navigation",
|
||||||
|
icon: mdiCogOutline,
|
||||||
|
title: t("command-palette.open-settings"),
|
||||||
|
keywords: "settings preferences options config",
|
||||||
|
perform: () => router.push("/settings"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commands shown before the user types anything: the context-sensitive
|
||||||
|
// container actions so e.g. Restart is one keystroke away on a container page.
|
||||||
|
const contextCommands = computed(() => commands.value.filter((c) => c.section === "container"));
|
||||||
|
|
||||||
|
return { commands, contextCommands };
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
|
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
|
||||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
<div class="modal-box max-w-2xl overflow-visible! bg-transparent pt-20 shadow-none">
|
||||||
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<form method="dialog" class="modal-backdrop">
|
||||||
@@ -100,7 +100,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
|
|||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.router-view {
|
.router-view {
|
||||||
padding-top: 75px;
|
padding-top: var(--mobile-nav-height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,6 +57,24 @@
|
|||||||
--color-error: var(--color-red);
|
--color-error: var(--color-red);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--color-error-content: oklch(98% 0.01 30);
|
||||||
|
|
||||||
|
/* ANSI log palette tuned for legibility on the dark background */
|
||||||
|
--ansi-black: oklch(45% 0 0);
|
||||||
|
--ansi-red: oklch(65% 0.2 25);
|
||||||
|
--ansi-green: oklch(72% 0.16 150);
|
||||||
|
--ansi-yellow: oklch(80% 0.13 85);
|
||||||
|
--ansi-blue: oklch(70% 0.14 250);
|
||||||
|
--ansi-magenta: oklch(68% 0.19 330);
|
||||||
|
--ansi-cyan: oklch(75% 0.11 200);
|
||||||
|
--ansi-white: oklch(89% 0 0);
|
||||||
|
--ansi-bright-black: oklch(60% 0 0);
|
||||||
|
--ansi-bright-red: oklch(72% 0.2 25);
|
||||||
|
--ansi-bright-green: oklch(82% 0.17 150);
|
||||||
|
--ansi-bright-yellow: oklch(88% 0.14 95);
|
||||||
|
--ansi-bright-blue: oklch(78% 0.13 250);
|
||||||
|
--ansi-bright-magenta: oklch(75% 0.19 330);
|
||||||
|
--ansi-bright-cyan: oklch(85% 0.11 200);
|
||||||
|
--ansi-bright-white: oklch(100% 0 0);
|
||||||
|
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.25rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.5rem;
|
||||||
@@ -97,6 +115,24 @@
|
|||||||
--color-error: var(--color-red);
|
--color-error: var(--color-red);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--color-error-content: oklch(98% 0.01 30);
|
||||||
|
|
||||||
|
/* ANSI log palette tuned for legibility on the light background */
|
||||||
|
--ansi-black: oklch(25% 0 0);
|
||||||
|
--ansi-red: oklch(52% 0.2 25);
|
||||||
|
--ansi-green: oklch(52% 0.15 150);
|
||||||
|
--ansi-yellow: oklch(60% 0.13 75);
|
||||||
|
--ansi-blue: oklch(50% 0.17 255);
|
||||||
|
--ansi-magenta: oklch(50% 0.2 330);
|
||||||
|
--ansi-cyan: oklch(55% 0.1 210);
|
||||||
|
--ansi-white: oklch(70% 0 0);
|
||||||
|
--ansi-bright-black: oklch(45% 0 0);
|
||||||
|
--ansi-bright-red: oklch(58% 0.21 25);
|
||||||
|
--ansi-bright-green: oklch(58% 0.16 150);
|
||||||
|
--ansi-bright-yellow: oklch(62% 0.13 85);
|
||||||
|
--ansi-bright-blue: oklch(55% 0.16 255);
|
||||||
|
--ansi-bright-magenta: oklch(56% 0.2 330);
|
||||||
|
--ansi-bright-cyan: oklch(60% 0.1 210);
|
||||||
|
--ansi-bright-white: oklch(85% 0 0);
|
||||||
|
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.25rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.5rem;
|
||||||
@@ -120,6 +156,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* daisyUI darkens the ghost button's border on hover, which reads as a
|
||||||
|
* near-black ring on the dark theme. Use a theme-aware base-content tint so the
|
||||||
|
* hover ring stays visible and legible in both themes (light on dark, dark on
|
||||||
|
* light) instead of a fixed near-black. */
|
||||||
|
.btn-ghost:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--color-base-content) 40%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
@utility menu-active {
|
@utility menu-active {
|
||||||
&,
|
&,
|
||||||
&:active {
|
&:active {
|
||||||
@@ -143,6 +187,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reserve the document scrollbar gutter so the layout width stays constant when
|
||||||
|
* a page-level scrollbar appears (e.g. once a container's logs load). Without
|
||||||
|
* this, the width jump makes the percentage-based splitpanes recompute and the
|
||||||
|
* sidebar visibly flicks. */
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
@@ -150,6 +202,13 @@ body {
|
|||||||
padding-right: env(safe-area-inset-right);
|
padding-right: env(safe-area-inset-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Height of the mobile top nav (logo row + border). The container status bar
|
||||||
|
* sticks right below it and the log content is padded by the same amount, so
|
||||||
|
* they stay locked together. The safe-area inset is already handled by body. */
|
||||||
|
:root {
|
||||||
|
--mobile-nav-height: 57px;
|
||||||
|
}
|
||||||
|
|
||||||
[class*="shadow-"] {
|
[class*="shadow-"] {
|
||||||
@apply shadow-base-content/8;
|
@apply shadow-base-content/8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import AnsiConvertor from "ansi-to-html";
|
||||||
|
|
||||||
|
// Theme-aware ANSI palette. Values are CSS variables defined per DaisyUI theme in main.css,
|
||||||
|
// so log colors stay legible on both light and dark backgrounds. The `colors` map merges over
|
||||||
|
// the library's 256-color defaults, so indices 16-255 keep their standard values.
|
||||||
|
const ansiConvertor = new AnsiConvertor({
|
||||||
|
escapeXML: false,
|
||||||
|
fg: "var(--color-base-content)",
|
||||||
|
bg: "var(--color-base-100)",
|
||||||
|
colors: {
|
||||||
|
0: "var(--ansi-black)",
|
||||||
|
1: "var(--ansi-red)",
|
||||||
|
2: "var(--ansi-green)",
|
||||||
|
3: "var(--ansi-yellow)",
|
||||||
|
4: "var(--ansi-blue)",
|
||||||
|
5: "var(--ansi-magenta)",
|
||||||
|
6: "var(--ansi-cyan)",
|
||||||
|
7: "var(--ansi-white)",
|
||||||
|
8: "var(--ansi-bright-black)",
|
||||||
|
9: "var(--ansi-bright-red)",
|
||||||
|
10: "var(--ansi-bright-green)",
|
||||||
|
11: "var(--ansi-bright-yellow)",
|
||||||
|
12: "var(--ansi-bright-blue)",
|
||||||
|
13: "var(--ansi-bright-magenta)",
|
||||||
|
14: "var(--ansi-bright-cyan)",
|
||||||
|
15: "var(--ansi-bright-white)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const colorize = (value: string): string => ansiConvertor.toHtml(value);
|
||||||
@@ -2,3 +2,4 @@ export { formatDuration, toRelativeTime } from "./date";
|
|||||||
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
||||||
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
||||||
export { formatBytes, stripVersion, hashCode } from "./format";
|
export { formatBytes, stripVersion, hashCode } from "./format";
|
||||||
|
export { colorize } from "./ansi";
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
x-dozzle-healthcheck: &dozzle-healthcheck
|
||||||
|
test: ["CMD", "/dozzle", "healthcheck"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
start_interval: 5s
|
||||||
|
|
||||||
services:
|
services:
|
||||||
custom_base:
|
custom_base:
|
||||||
container_name: custom_base
|
container_name: custom_base
|
||||||
@@ -10,6 +17,7 @@ services:
|
|||||||
- DOZZLE_HOSTNAME=custom name
|
- DOZZLE_HOSTNAME=custom name
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
simple-auth:
|
simple-auth:
|
||||||
@@ -20,6 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DOZZLE_AUTH_PROVIDER=simple
|
- DOZZLE_AUTH_PROVIDER=simple
|
||||||
- DOZZLE_NO_ANALYTICS=1
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dozzle:
|
dozzle:
|
||||||
@@ -33,8 +42,33 @@ services:
|
|||||||
- DOZZLE_LEVEL=debug
|
- DOZZLE_LEVEL=debug
|
||||||
ports:
|
ports:
|
||||||
- 7070:8080
|
- 7070:8080
|
||||||
|
# Deliberately no healthcheck: this container is the subject of the visual
|
||||||
|
# snapshots, and a healthcheck makes a health badge appear in the sidebar
|
||||||
|
# (HostMenu.vue), which shifts the reference images.
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
# Dedicated instance + noisy container so log streaming can be asserted without
|
||||||
|
# perturbing the `name=dozzle` filter the other instances rely on.
|
||||||
|
logs-viewer:
|
||||||
|
container_name: logs-viewer
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
- DOZZLE_FILTER=name=logspam
|
||||||
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
|
- DOZZLE_HOSTNAME=logs
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
depends_on:
|
||||||
|
- logspam
|
||||||
|
logspam:
|
||||||
|
container_name: logspam
|
||||||
|
image: alpine:3
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
|
||||||
remote:
|
remote:
|
||||||
container_name: remote
|
container_name: remote
|
||||||
environment:
|
environment:
|
||||||
@@ -43,6 +77,7 @@ services:
|
|||||||
- DOZZLE_NO_ANALYTICS=1
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
ports:
|
ports:
|
||||||
- 5050:8080
|
- 5050:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -56,6 +91,7 @@ services:
|
|||||||
- DOZZLE_LEVEL=debug
|
- DOZZLE_LEVEL=debug
|
||||||
ports:
|
ports:
|
||||||
- 8082:8080
|
- 8082:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -85,6 +121,9 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- CONTAINERS=1
|
- CONTAINERS=1
|
||||||
|
# Dozzle calls /info on connect; without it the host never initializes and
|
||||||
|
# lists zero containers.
|
||||||
|
- INFO=1
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -96,7 +135,7 @@ services:
|
|||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
container_name: playwright
|
container_name: playwright
|
||||||
image: mcr.microsoft.com/playwright:v1.61.1-jammy
|
image: mcr.microsoft.com/playwright:v1.62.1-jammy
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
@@ -105,6 +144,15 @@ services:
|
|||||||
- PWTEST_SKIP_TEST_OUTPUT=1
|
- PWTEST_SKIP_TEST_OUTPUT=1
|
||||||
- CI=1
|
- CI=1
|
||||||
depends_on:
|
depends_on:
|
||||||
- dozzle
|
dozzle:
|
||||||
- custom_base
|
condition: service_started
|
||||||
- remote
|
custom_base:
|
||||||
|
condition: service_healthy
|
||||||
|
remote:
|
||||||
|
condition: service_healthy
|
||||||
|
simple-auth:
|
||||||
|
condition: service_healthy
|
||||||
|
dozzle-with-agent:
|
||||||
|
condition: service_healthy
|
||||||
|
logs-viewer:
|
||||||
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
declare const data: { stars: number; pulls: number };
|
declare const data: {
|
||||||
|
stars: number;
|
||||||
|
pulls: number;
|
||||||
|
contributors: number;
|
||||||
|
release: { version: string; date: string } | null;
|
||||||
|
};
|
||||||
export { data };
|
export { data };
|
||||||
|
|
||||||
|
// The contributors endpoint has no total count, so ask for one per page and read
|
||||||
|
// the last page number out of the Link header.
|
||||||
|
async function fetchContributorCount() {
|
||||||
|
const res = await fetch("https://api.github.com/repos/amir20/dozzle/contributors?per_page=1&anon=false");
|
||||||
|
const match = res.headers.get("link")?.match(/[?&]page=(\d+)>; rel="last"/);
|
||||||
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestRelease() {
|
||||||
|
const res = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.tag_name) return null;
|
||||||
|
return { version: json.tag_name, date: json.published_at };
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async load() {
|
async load() {
|
||||||
const urls = [
|
const urls = [
|
||||||
@@ -8,13 +28,18 @@ export default {
|
|||||||
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
|
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
|
||||||
];
|
];
|
||||||
|
|
||||||
const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json())));
|
const [repo, hub, contributors, release] = await Promise.all([
|
||||||
|
...urls.map((url) => fetch(url).then((res) => res.json())),
|
||||||
|
// Never fail the build over a rate-limited or flaky API call.
|
||||||
|
fetchContributorCount().catch(() => 0),
|
||||||
|
fetchLatestRelease().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
const data = {
|
return {
|
||||||
stars: responses[0].stargazers_count,
|
stars: repo.stargazers_count,
|
||||||
pulls: responses[1].pull_count,
|
pulls: hub.pull_count,
|
||||||
|
contributors,
|
||||||
|
release,
|
||||||
};
|
};
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export default defineConfig({
|
|||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
|
|
||||||
head: [
|
head: [
|
||||||
|
["meta", { property: "og:type", content: "website" }],
|
||||||
|
["meta", { property: "og:site_name", content: "Dozzle" }],
|
||||||
|
["meta", { property: "og:url", content: "https://dozzle.dev/" }],
|
||||||
|
["meta", { property: "og:image", content: "https://dozzle.dev/og-image.png" }],
|
||||||
|
["meta", { property: "og:image:width", content: "1200" }],
|
||||||
|
["meta", { property: "og:image:height", content: "630" }],
|
||||||
|
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||||
|
["meta", { name: "twitter:image", content: "https://dozzle.dev/og-image.png" }],
|
||||||
[
|
[
|
||||||
"script",
|
"script",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { withBase } from "vitepress";
|
||||||
|
import InstallCommand from "./InstallCommand.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center rounded-2xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) px-6 py-16"
|
||||||
|
>
|
||||||
|
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
|
||||||
|
Start watching your containers
|
||||||
|
</h2>
|
||||||
|
<p class="mt-3! mb-0! max-w-xl text-center text-(--vp-c-text-2)">
|
||||||
|
One command, no agent to install, no account to create.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 w-full">
|
||||||
|
<InstallCommand :hint="false" dense />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<a class="cta-button cta-brand" :href="withBase('/guide/getting-started')">Get Started</a>
|
||||||
|
<a class="cta-button cta-alt" href="https://github.com/amir20/dozzle">
|
||||||
|
<Icon icon="simple-icons:github" class="size-4" />
|
||||||
|
View on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-8! mb-0! text-sm text-(--vp-c-text-2)">MIT licensed and free forever.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Mirror VitePress's own button tokens so these match the hero buttons in both themes. */
|
||||||
|
.cta-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-decoration: none !important;
|
||||||
|
transition:
|
||||||
|
background-color 0.25s,
|
||||||
|
border-color 0.25s,
|
||||||
|
color 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-brand {
|
||||||
|
border-color: var(--vp-button-brand-border);
|
||||||
|
background-color: var(--vp-button-brand-bg);
|
||||||
|
color: var(--vp-button-brand-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-brand:hover {
|
||||||
|
border-color: var(--vp-button-brand-hover-border);
|
||||||
|
background-color: var(--vp-button-brand-hover-bg);
|
||||||
|
color: var(--vp-button-brand-hover-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-alt {
|
||||||
|
border-color: var(--vp-button-alt-border);
|
||||||
|
background-color: var(--vp-button-alt-bg);
|
||||||
|
color: var(--vp-button-alt-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-alt:hover {
|
||||||
|
border-color: var(--vp-button-alt-hover-border);
|
||||||
|
background-color: var(--vp-button-alt-hover-bg);
|
||||||
|
color: var(--vp-button-alt-hover-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { data } from "../../activity.data";
|
||||||
|
|
||||||
|
const cues = [
|
||||||
|
{ icon: "mdi:scale-balance", label: "MIT licensed" },
|
||||||
|
{ icon: "mdi:package-variant-closed", label: "Single binary, no database" },
|
||||||
|
{ icon: "mdi:server-security", label: "Self-hosted, your logs stay on your network" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const released = computed(() => {
|
||||||
|
if (!data.release) return null;
|
||||||
|
const days = Math.floor((Date.now() - new Date(data.release.date).getTime()) / 86_400_000);
|
||||||
|
if (days <= 0) return "released today";
|
||||||
|
if (days === 1) return "released yesterday";
|
||||||
|
if (days < 30) return `released ${days} days ago`;
|
||||||
|
const months = Math.round(days / 30);
|
||||||
|
return `released ${months} month${months === 1 ? "" : "s"} ago`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-8 flex flex-col gap-2 text-sm text-(--vp-c-text-2)">
|
||||||
|
<ul class="m-0! flex list-none flex-wrap gap-x-5 gap-y-2 p-0!">
|
||||||
|
<li v-for="cue in cues" :key="cue.label" class="flex items-center gap-1.5">
|
||||||
|
<Icon :icon="cue.icon" class="size-4 opacity-70" />
|
||||||
|
{{ cue.label }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a
|
||||||
|
v-if="data.release"
|
||||||
|
class="flex w-fit items-center gap-1.5 text-(--vp-c-text-3) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||||
|
:href="`https://github.com/amir20/dozzle/releases/tag/${data.release.version}`"
|
||||||
|
>
|
||||||
|
<span class="relative flex size-2">
|
||||||
|
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60"></span>
|
||||||
|
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
{{ data.release.version }} {{ released }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,36 +1,49 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useMutationObserver } from "@vueuse/core";
|
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||||
import { onMounted, ref } from "vue";
|
import { useData } from "vitepress";
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
const isDark = ref(false);
|
import darkPoster from "../media/poster-dark.webp";
|
||||||
onMounted(() => {
|
import lightPoster from "../media/poster-light.webp";
|
||||||
isDark.value = document.documentElement.classList.contains("dark");
|
import darkVideo from "../media/dozzle-dark.mp4";
|
||||||
useMutationObserver(
|
import lightVideo from "../media/dozzle-light.mp4";
|
||||||
document.documentElement,
|
|
||||||
(mutations) => {
|
const { isDark } = useData();
|
||||||
isDark.value = document.documentElement.classList.contains("dark");
|
const reducedMotion = usePreferredReducedMotion();
|
||||||
},
|
|
||||||
{
|
// The posters are rendered on the server and toggled with CSS so the first paint
|
||||||
attributes: true,
|
// is already the right theme. The video only mounts on the client, which keeps
|
||||||
attributeFilter: ["class"],
|
// markup identical between SSR and hydration and avoids downloading both files.
|
||||||
},
|
const mounted = ref(false);
|
||||||
);
|
onMounted(() => (mounted.value = true));
|
||||||
});
|
|
||||||
|
const playVideo = computed(() => mounted.value && reducedMotion.value !== "reduce");
|
||||||
|
const video = computed(() => (isDark.value ? darkVideo : lightVideo));
|
||||||
|
const poster = computed(() => (isDark.value ? darkPoster : lightPoster));
|
||||||
|
|
||||||
|
const alt = "The Dozzle interface streaming container logs in real time";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] text-[red] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
class="relative overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||||
>
|
>
|
||||||
<video muted loop autoplay playsinline poster="../media/poster-dark.png" v-if="isDark">
|
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
|
||||||
<source src="../media/dozzle-dark.mp4" type="video/mp4" />
|
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
|
||||||
<img src="../media/poster-dark.png" alt="" />
|
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
|
||||||
</video>
|
<video
|
||||||
<video muted loop autoplay playsinline poster="../media/poster-light.png" v-else>
|
v-if="playVideo"
|
||||||
<source src="../media/dozzle-light.mp4" type="video/mp4" />
|
:key="video"
|
||||||
<img src="../media/poster-light.png" alt="" />
|
class="absolute inset-0 size-full"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
aria-hidden="true"
|
||||||
|
:poster="poster"
|
||||||
|
>
|
||||||
|
<source :src="video" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
import { withBase } from "vitepress";
|
||||||
|
|
||||||
|
const { hint = true, dense = false } = defineProps<{
|
||||||
|
hint?: boolean;
|
||||||
|
dense?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const command =
|
||||||
|
"docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest";
|
||||||
|
|
||||||
|
const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto flex max-w-[1152px] flex-col items-center gap-3 px-6" :class="dense ? 'mb-0' : 'mb-14'">
|
||||||
|
<div
|
||||||
|
class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 font-mono text-xs leading-6 text-(--vp-c-text-3) select-none md:text-sm">$</span>
|
||||||
|
<code
|
||||||
|
class="min-w-0 flex-1 bg-transparent! p-0! font-mono text-xs leading-6 break-words whitespace-pre-wrap text-(--vp-c-text-1) md:text-sm"
|
||||||
|
>{{ command }}</code
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 cursor-pointer rounded-md border border-solid border-(--vp-c-divider) bg-(--vp-c-bg) px-3 py-1.5 text-xs font-medium text-(--vp-c-text-2) transition-colors hover:border-(--vp-c-brand) hover:text-(--vp-c-brand)"
|
||||||
|
:aria-label="copied ? 'Command copied' : 'Copy command to clipboard'"
|
||||||
|
@click="copy()"
|
||||||
|
>
|
||||||
|
{{ copied ? "Copied" : "Copy" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="hint" class="m-0! text-sm text-(--vp-c-text-2)">
|
||||||
|
Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the
|
||||||
|
<a :href="withBase('/guide/getting-started')">install guide</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { withBase } from "vitepress";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-16 mb-4 flex items-center justify-center px-6">
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-2 text-sm text-(--vp-c-text-2) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||||
|
href="https://www.docker.com/blog/docker-sponsored-open-source-program/"
|
||||||
|
>
|
||||||
|
<img :src="withBase('/icons/docker-icon.svg')" width="20" height="20" alt="" />
|
||||||
|
Open source and part of Docker's sponsored OSS program
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,21 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="stats stats-horizontal mt-10">
|
<div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
|
||||||
<div class="stat w-40 border-0!">
|
<a
|
||||||
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div>
|
v-for="stat in stats"
|
||||||
<div class="stat-value">
|
:key="stat.label"
|
||||||
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="(value) => (value / 1e3).toFixed(1) + 'K'" />
|
class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||||
</div>
|
:href="stat.href"
|
||||||
</div>
|
>
|
||||||
|
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
|
||||||
<div class="stat">
|
<Icon :icon="stat.icon" class="size-4 opacity-70" />
|
||||||
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div>
|
{{ stat.label }}
|
||||||
<div class="stat-value">
|
</span>
|
||||||
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="(value) => (value / 1e6).toFixed(0) + 'M'" />
|
<span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
|
||||||
</div>
|
<Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
|
||||||
</div>
|
</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
import { data } from "../../activity.data";
|
import { data } from "../../activity.data";
|
||||||
import Counter from "./Counter.vue";
|
import Counter from "./Counter.vue";
|
||||||
|
|
||||||
|
const stats = computed(() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
icon: "simple-icons:github",
|
||||||
|
label: "Github Stars",
|
||||||
|
value: data.stars,
|
||||||
|
href: "https://github.com/amir20/dozzle/stargazers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "simple-icons:docker",
|
||||||
|
label: "Docker Pulls",
|
||||||
|
value: data.pulls,
|
||||||
|
href: "https://hub.docker.com/r/amir20/dozzle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "mdi:account-group-outline",
|
||||||
|
label: "Contributors",
|
||||||
|
value: data.contributors,
|
||||||
|
href: "https://github.com/amir20/dozzle/graphs/contributors",
|
||||||
|
},
|
||||||
|
].filter((stat) => stat.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const compact = (value: number) => {
|
||||||
|
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B";
|
||||||
|
if (value >= 1e6) return Math.round(value / 1e6) + "M";
|
||||||
|
if (value >= 1e3) return (value / 1e3).toFixed(1) + "K";
|
||||||
|
return Math.round(value).toString();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
// Real quotes only. Drop in verbatim text with a link back to the source
|
||||||
|
// (GitHub issue, HN thread, r/selfhosted post) so anyone can check it.
|
||||||
|
// The section hides itself while this is empty.
|
||||||
|
type Testimonial = { quote: string; author: string; source: string; href: string };
|
||||||
|
|
||||||
|
const testimonials: Testimonial[] = [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-if="testimonials.length" class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||||
|
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">What people say</h2>
|
||||||
|
|
||||||
|
<div class="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
<figure
|
||||||
|
v-for="testimonial in testimonials"
|
||||||
|
:key="testimonial.href"
|
||||||
|
class="m-0! flex flex-col rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:format-quote-close" class="size-6 text-(--vp-c-text-3) opacity-60" />
|
||||||
|
<blockquote class="mt-3! mb-0! border-0! p-0! text-(--vp-c-text-1)">{{ testimonial.quote }}</blockquote>
|
||||||
|
<figcaption class="mt-4! text-sm text-(--vp-c-text-2)">
|
||||||
|
{{ testimonial.author }} on
|
||||||
|
<a :href="testimonial.href">{{ testimonial.source }}</a>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
cli: "One container at a time, per host.",
|
||||||
|
dozzle: "Follow many containers side by side, merged across every host you connect.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cli: "Pipe to grep and start over when you get it wrong.",
|
||||||
|
dozzle: "Search, filter by level, and jump to a timestamp while the logs keep streaming.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cli: "JSON and stack traces arrive as a wall of text.",
|
||||||
|
dozzle: "Structured logs get parsed into fields, multi-line traces stay grouped.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cli: "Separate docker stats window for CPU and memory.",
|
||||||
|
dozzle: "Live CPU, memory, and network charts on the same screen as the logs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cli: "SSH into each machine to look around.",
|
||||||
|
dozzle: "One browser tab over Docker, Swarm, and Kubernetes, with a shell when you need it.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||||
|
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
|
||||||
|
Why not just <code class="bg-transparent! p-0! font-mono">docker logs</code>?
|
||||||
|
</h2>
|
||||||
|
<p class="mx-auto mt-3! mb-0! max-w-2xl text-center text-(--vp-c-text-2)">
|
||||||
|
It works fine until you are tailing three containers on two hosts at 2am.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-10 grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-text-3) uppercase">
|
||||||
|
<Icon icon="mdi:console" class="size-4" />
|
||||||
|
docker logs
|
||||||
|
</div>
|
||||||
|
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
|
||||||
|
<li v-for="row in rows" :key="row.cli" class="flex gap-2.5 text-(--vp-c-text-2)">
|
||||||
|
<Icon icon="mdi:close-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-text-3)" />
|
||||||
|
<span>{{ row.cli }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-solid border-(--vp-c-brand-dimm) bg-(--vp-c-brand-dimm) p-6">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-brand) uppercase">
|
||||||
|
<Icon icon="mdi:television-play" class="size-4" />
|
||||||
|
Dozzle
|
||||||
|
</div>
|
||||||
|
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
|
||||||
|
<li v-for="row in rows" :key="row.dozzle" class="flex gap-2.5 text-(--vp-c-text-1)">
|
||||||
|
<Icon icon="mdi:check-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-brand)" />
|
||||||
|
<span>{{ row.dozzle }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -8,7 +8,13 @@ import "./style.css";
|
|||||||
import HeroVideo from "./components/HeroVideo.vue";
|
import HeroVideo from "./components/HeroVideo.vue";
|
||||||
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
||||||
import Stats from "./components/Stats.vue";
|
import Stats from "./components/Stats.vue";
|
||||||
|
import HeroTrust from "./components/HeroTrust.vue";
|
||||||
import Supported from "./components/Supported.vue";
|
import Supported from "./components/Supported.vue";
|
||||||
|
import InstallCommand from "./components/InstallCommand.vue";
|
||||||
|
import WhyDozzle from "./components/WhyDozzle.vue";
|
||||||
|
import Testimonials from "./components/Testimonials.vue";
|
||||||
|
import FinalCta from "./components/FinalCta.vue";
|
||||||
|
import SponsoredBy from "./components/SponsoredBy.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...DefaultTheme,
|
...DefaultTheme,
|
||||||
@@ -16,8 +22,9 @@ export default {
|
|||||||
return h(DefaultTheme.Layout, null, {
|
return h(DefaultTheme.Layout, null, {
|
||||||
"home-hero-image": () => h(HeroVideo),
|
"home-hero-image": () => h(HeroVideo),
|
||||||
"sidebar-nav-after": () => h(BuyMeCoffee),
|
"sidebar-nav-after": () => h(BuyMeCoffee),
|
||||||
"home-hero-actions-after": () => h(Stats),
|
"home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
|
||||||
"home-hero-after": () => h(Supported),
|
"home-hero-after": () => [h(InstallCommand), h(Supported)],
|
||||||
|
"home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enhanceApp(ctx) {
|
enhanceApp(ctx) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -154,8 +154,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.VPHomeHero .text {
|
||||||
|
@apply text-3xl font-normal md:text-5xl;
|
||||||
|
}
|
||||||
|
|
||||||
.VPHomeHero .tagline {
|
.VPHomeHero .tagline {
|
||||||
@apply font-sans text-4xl font-light;
|
@apply font-sans text-lg font-light md:text-2xl;
|
||||||
color: var(--tag-line-color);
|
color: var(--tag-line-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
||||||
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
|
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
|
||||||
|
FinalCta: typeof import('./.vitepress/theme/components/FinalCta.vue')['default']
|
||||||
|
HeroTrust: typeof import('./.vitepress/theme/components/HeroTrust.vue')['default']
|
||||||
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
|
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
|
||||||
|
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
|
||||||
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
|
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
|
||||||
Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default']
|
Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default']
|
||||||
|
Testimonials: typeof import('./.vitepress/theme/components/Testimonials.vue')['default']
|
||||||
|
WhyDozzle: typeof import('./.vitepress/theme/components/WhyDozzle.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Dozzle has access to `docker.sock`, which — unless restricted — is equivalen
|
|||||||
- **Keep [actions](/guide/actions) and [shell access](/guide/shell) disabled** unless you need them. They allow starting, stopping, recreating, and executing arbitrary commands inside containers.
|
- **Keep [actions](/guide/actions) and [shell access](/guide/shell) disabled** unless you need them. They allow starting, stopping, recreating, and executing arbitrary commands inside containers.
|
||||||
- **Restrict users with [roles](#setting-specific-roles-for-users) and [filters](#setting-specific-filters-for-users)** in multi-user mode. Without explicit roles, a user can see every container the Dozzle instance can.
|
- **Restrict users with [roles](#setting-specific-roles-for-users) and [filters](#setting-specific-filters-for-users)** in multi-user mode. Without explicit roles, a user can see every container the Dozzle instance can.
|
||||||
- **Run TLS at the reverse proxy**. See [Reverse Proxy & Base Path](/guide/changing-base) for Nginx / Traefik / Caddy examples.
|
- **Run TLS at the reverse proxy**. See [Reverse Proxy & Base Path](/guide/changing-base) for Nginx / Traefik / Caddy examples.
|
||||||
- **Avoid mounting `docker.sock` read-write** if you don't need actions. A read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) still exposes most of the API but blocks container create/delete/update.
|
- **Restrict `docker.sock` access with a proxy** if you don't need actions. Note that a read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) does _not_ limit the API: the `:ro` flag only marks the socket file read-only on disk, while API calls still pass through the socket normally, so create/delete/update remain possible. To actually restrict operations, put a socket proxy like [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy) in front of the daemon.
|
||||||
|
|
||||||
## <Icon icon="mdi:account-cog-outline" inline /> File-Based User Management
|
## <Icon icon="mdi:account-cog-outline" inline /> File-Based User Management
|
||||||
|
|
||||||
@@ -189,6 +189,14 @@ docker run -it --rm amir20/dozzle generate admin --password password --email tes
|
|||||||
|
|
||||||
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. The `--user-roles` flag is a comma-separated list of roles.
|
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. The `--user-roles` flag is a comma-separated list of roles.
|
||||||
|
|
||||||
|
If you omit `--password`, Dozzle prompts for it on stdin so the password never lands in your shell history. This requires an interactive terminal, so keep the `-it` flags:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -it --rm amir20/dozzle generate admin --email test@email.net --name "John Doe" > users.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The prompt is written to stderr, so redirecting stdout to `users.yml` still works. You can also pipe the password in, for example `echo "$PASSWORD" | docker run -i --rm amir20/dozzle generate admin > users.yml`.
|
||||||
|
|
||||||
## <Icon icon="mdi:swap-horizontal" inline /> Forward Proxy
|
## <Icon icon="mdi:swap-horizontal" inline /> Forward Proxy
|
||||||
|
|
||||||
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
|
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ All tools are **read-only** and do not modify containers.
|
|||||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||||
| `list_containers` | List all containers across all hosts. Supports optional `state` filter. |
|
| `list_containers` | List all containers across all hosts. Supports optional `state` filter. |
|
||||||
| `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. |
|
| `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. |
|
||||||
|
| `search_container_logs`| Search container logs for a keyword or phrase. Returns only matching entries. |
|
||||||
| `list_hosts` | List all connected Docker hosts. |
|
| `list_hosts` | List all connected Docker hosts. |
|
||||||
| `get_container_stats` | Get CPU and memory usage history for a container. |
|
| `get_container_stats` | Get CPU and memory usage history for a container. |
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
# https://vitepress.dev/reference/default-theme-home-page
|
# https://vitepress.dev/reference/default-theme-home-page
|
||||||
layout: home
|
layout: home
|
||||||
|
|
||||||
title: Home
|
titleTemplate: Real-time Docker Log Viewer
|
||||||
|
description: Dozzle is a lightweight, open-source log viewer for Docker, Swarm, and Kubernetes. Stream logs, watch live stats, and debug containers from your browser.
|
||||||
|
|
||||||
hero:
|
hero:
|
||||||
name: "Dozzle"
|
name: "Dozzle"
|
||||||
|
text: "See what your containers are doing"
|
||||||
tagline: Real-time Docker logs, stats, and debugging — in your browser.
|
tagline: Real-time Docker logs, stats, and debugging — in your browser.
|
||||||
actions:
|
actions:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
@@ -22,12 +24,16 @@ features:
|
|||||||
src: /icons/document.svg
|
src: /icons/document.svg
|
||||||
width: 36
|
width: 36
|
||||||
height: 36
|
height: 36
|
||||||
|
link: /guide/what-is-dozzle#advanced-log-handling
|
||||||
|
linkText: Learn More
|
||||||
- title: Live Stats & Metrics
|
- title: Live Stats & Metrics
|
||||||
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
|
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
|
||||||
icon:
|
icon:
|
||||||
src: /icons/chart-line-data.svg
|
src: /icons/chart-line-data.svg
|
||||||
width: 36
|
width: 36
|
||||||
height: 36
|
height: 36
|
||||||
|
link: /guide/what-is-dozzle#real-time-monitoring
|
||||||
|
linkText: Learn More
|
||||||
- title: SQL Log Analysis
|
- title: SQL Log Analysis
|
||||||
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
|
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
|
||||||
icon:
|
icon:
|
||||||
@@ -52,12 +58,6 @@ features:
|
|||||||
height: 36
|
height: 36
|
||||||
link: /guide/remote-hosts
|
link: /guide/remote-hosts
|
||||||
linkText: Learn More
|
linkText: Learn More
|
||||||
- title: Self-hosted & Private
|
|
||||||
details: Runs in your own infrastructure. Your logs never leave your network.
|
|
||||||
icon:
|
|
||||||
src: /icons/locked.svg
|
|
||||||
width: 36
|
|
||||||
height: 36
|
|
||||||
- title: Shell & Exec Access
|
- title: Shell & Exec Access
|
||||||
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
|
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
|
||||||
icon:
|
icon:
|
||||||
@@ -66,12 +66,28 @@ features:
|
|||||||
height: 36
|
height: 36
|
||||||
link: /guide/shell
|
link: /guide/shell
|
||||||
linkText: Learn More
|
linkText: Learn More
|
||||||
- title: Officially Sponsored by Docker
|
- title: dtop in Your Terminal
|
||||||
details: Open source and part of Docker's sponsored OSS program.
|
details: A command-line companion that gives you a live view of your containers, then jumps straight into Dozzle.
|
||||||
icon:
|
icon:
|
||||||
src: /icons/docker-icon.svg
|
src: /icons/terminal-command.svg
|
||||||
width: 36
|
width: 36
|
||||||
height: 36
|
height: 36
|
||||||
link: https://hub.docker.com/r/amir20/dozzle
|
link: /guide/dtop
|
||||||
linkText: Docker Hub
|
linkText: Learn More
|
||||||
|
- title: MCP for AI Assistants
|
||||||
|
details: Expose containers, logs, and stats over the Model Context Protocol so your coding agent can debug alongside you.
|
||||||
|
icon:
|
||||||
|
src: /icons/ai.svg
|
||||||
|
width: 36
|
||||||
|
height: 36
|
||||||
|
link: /guide/mcp
|
||||||
|
linkText: Learn More
|
||||||
|
- title: Self-hosted & Private
|
||||||
|
details: Runs in your own infrastructure with simple or forward-proxy auth. Your logs never leave your network.
|
||||||
|
icon:
|
||||||
|
src: /icons/locked.svg
|
||||||
|
width: 36
|
||||||
|
height: 36
|
||||||
|
link: /guide/authentication
|
||||||
|
linkText: Learn More
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"dozzle": "workspace:*"
|
"dozzle": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8"
|
"@fontsource-variable/playfair-display": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#646cff" d="m14 3l2.4 6.6L23 12l-6.6 2.4L14 21l-2.4-6.6L5 12l6.6-2.4z"/><path fill="#646cff" d="m24 18l1.4 3.6L29 23l-3.6 1.4L24 28l-1.4-3.6L19 23l3.6-1.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#646cff" d="M26 4.01H6a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-20a2 2 0 0 0-2-2m0 2v4H6v-4Zm-20 20v-14h20v14Z"/><path fill="#646cff" d="M9 18h3v6H9zm5.5-3h3v9h-3zM20 20h3v4h-3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 126 KiB |
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
Components({
|
Components({
|
||||||
dirs: [path.resolve(__dirname, ".vitepress/theme/components")],
|
dirs: [path.resolve(import.meta.dirname, ".vitepress/theme/components")],
|
||||||
extensions: ["vue", "md"],
|
extensions: ["vue", "md"],
|
||||||
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
|
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
|
||||||
dts: true,
|
dts: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("http://dozzle-with-agent:8080/");
|
await page.goto("http://dozzle-with-agent:8080/");
|
||||||
});
|
});
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("http://remote:8080/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has right title", async ({ page }) => {
|
|
||||||
await expect(page).toHaveTitle(/.* - Dozzle/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("select running container", async ({ page }) => {
|
|
||||||
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/container/);
|
|
||||||
await expect(page.getByText("Accepting connections")).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
const origin = "http://custom_base:8080";
|
||||||
|
const base = "/foobarbase";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("http://custom_base:8080/foobarbase");
|
await page.goto(origin + base);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("has right title", async ({ page }) => {
|
test("has right title", async ({ page }) => {
|
||||||
@@ -11,3 +14,35 @@ test("has right title", async ({ page }) => {
|
|||||||
test("url should have custom base", async ({ page }) => {
|
test("url should have custom base", async ({ page }) => {
|
||||||
await expect(page).toHaveURL(/foobarbase/);
|
await expect(page).toHaveURL(/foobarbase/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("static assets resolve under the custom base", async ({ page }) => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
const broken: string[] = [];
|
||||||
|
|
||||||
|
const isAsset = (url: string) => new URL(url).pathname.includes("/assets/");
|
||||||
|
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (isAsset(request.url())) requested.push(new URL(request.url()).pathname);
|
||||||
|
});
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (isAsset(response.url()) && response.status() >= 400) {
|
||||||
|
broken.push(`${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||||
|
// fonts are only fetched when a glyph needs them, so force the @font-face src to resolve.
|
||||||
|
// swallow the rejection on a bad url so the assertions below report which path was wrong
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.fonts.load('400 1rem "JetBrains Mono"').then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// the css lives at <base>/assets/, so a relative url() in it must resolve back under <base>
|
||||||
|
expect(requested.some((path) => path.endsWith(".woff2"))).toBe(true);
|
||||||
|
expect(requested.filter((path) => !path.startsWith(`${base}/assets/`))).toEqual([]);
|
||||||
|
expect(broken).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Targets a dedicated instance filtered to the `logspam` container, which prints a
|
||||||
|
// numbered line every second. That makes "logs are actually streaming" assertable
|
||||||
|
// without depending on incidental output from Dozzle itself.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("http://logs-viewer:8080/");
|
||||||
|
await page.getByTestId("side-menu").getByRole("link", { name: "logspam" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/container\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders historical logs on load", async ({ page }) => {
|
||||||
|
await expect(page.locator("ul[data-logs] > li").first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("no-logs")).toBeHidden();
|
||||||
|
await expect(page.getByText(/logspam line \d+/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams new logs over SSE without a reload", async ({ page }) => {
|
||||||
|
const entries = page.locator("ul[data-logs] > li");
|
||||||
|
await expect(entries.first()).toBeVisible();
|
||||||
|
|
||||||
|
const initial = await entries.count();
|
||||||
|
// logspam emits ~1 line/sec, so a few new entries must arrive on the open stream.
|
||||||
|
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the newest log entry in view while streaming", async ({ page }) => {
|
||||||
|
const entries = page.locator("ul[data-logs] > li");
|
||||||
|
await expect(entries.first()).toBeVisible();
|
||||||
|
|
||||||
|
const initial = await entries.count();
|
||||||
|
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
|
||||||
|
await expect(entries.last()).toBeInViewport();
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ ...devices["Pixel 5"] });
|
||||||
|
|
||||||
|
test.describe("mobile stats", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("http://dozzle:8080/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows CPU and memory stats for containers on the dashboard", async ({ page }) => {
|
||||||
|
const row = page.locator("tr").filter({ has: page.getByTitle("dozzle") });
|
||||||
|
await expect(row.getByText(/^\d+%$/)).toBeVisible();
|
||||||
|
await expect(row.getByText(/^[\d.]+ ?[KMGT]?B$/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("locks the container stats header directly under the mobile nav, with no gap or overlap", async ({ page }) => {
|
||||||
|
const row = page.locator("tr").filter({ has: page.getByTitle("dozzle") });
|
||||||
|
await row.getByTitle("dozzle").click();
|
||||||
|
await expect(page).toHaveURL(/\/container\//);
|
||||||
|
|
||||||
|
const nav = await page.getByTestId("navigation").boundingBox();
|
||||||
|
const header = await page.getByTestId("scrollable-header").boundingBox();
|
||||||
|
|
||||||
|
expect(nav).not.toBeNull();
|
||||||
|
expect(header).not.toBeNull();
|
||||||
|
// The stats header must sit flush against the bottom of the fixed nav bar:
|
||||||
|
// any gap exposes empty space, any overlap clips the CPU/MEM chips underneath the nav.
|
||||||
|
expect(Math.abs(header!.y - (nav!.y + nav!.height))).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Covers DOZZLE_REMOTE_HOST (tcp:// through the socket proxy), a different code path
|
||||||
|
// than DOZZLE_REMOTE_AGENT. See agent.spec.ts for that one.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("http://remote:8080/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has right title", async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the labeled remote host", async ({ page }) => {
|
||||||
|
// The host column on the dashboard, rather than a bare getByText: the label also
|
||||||
|
// appears in the sidebar and the merge link, and which of them exist depends on
|
||||||
|
// whether containers have loaded yet.
|
||||||
|
await expect(page.getByRole("cell", { name: "remote-host" }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select running container", async ({ page }) => {
|
||||||
|
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/container/);
|
||||||
|
await expect(page.getByText("Accepting connections")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,9 +1,59 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test("simple authentication", async ({ page }) => {
|
const BASE = "http://simple-auth:8080";
|
||||||
await page.goto("http://simple-auth:8080/");
|
|
||||||
|
async function login(page: import("@playwright/test").Page) {
|
||||||
await page.locator('input[name="username"]').fill("admin");
|
await page.locator('input[name="username"]').fill("admin");
|
||||||
await page.locator('input[name="password"]').fill("password");
|
await page.locator('input[name="password"]').fill("password");
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("simple authentication", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await login(page);
|
||||||
await expect(page.getByTestId("settings")).toBeVisible();
|
await expect(page.getByTestId("settings")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("unauthenticated request redirects to the login page", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves the requested url as redirectUrl", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/settings");
|
||||||
|
await expect(page).toHaveURL(/\/login\?redirectUrl=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a wrong password", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await page.locator('input[name="username"]').fill("admin");
|
||||||
|
await page.locator('input[name="password"]').fill("not-the-password");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Username or password are not valid")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("settings")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("api rejects unauthenticated requests with 401", async ({ request }) => {
|
||||||
|
// A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
|
||||||
|
for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
|
||||||
|
const response = await request.get(BASE + path, { maxRedirects: 0 });
|
||||||
|
expect(response.status(), `${path} should be unauthorized`).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout clears the session", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await login(page);
|
||||||
|
await expect(page.getByTestId("settings")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("user-menu").locator("label").click();
|
||||||
|
await page.getByRole("button", { name: "Logout" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
|
||||||
|
// The cookie must be gone server-side, not just visually logged out.
|
||||||
|
const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -21,44 +21,47 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.12.0
|
github.com/PuerkitoBio/goquery v1.12.0
|
||||||
github.com/andybalholm/brotli v1.2.2
|
github.com/andybalholm/brotli v1.2.2
|
||||||
github.com/compose-spec/compose-go/v2 v2.13.0
|
github.com/compose-spec/compose-go/v2 v2.14.0
|
||||||
github.com/expr-lang/expr v1.17.8
|
github.com/expr-lang/expr v1.17.8
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||||
github.com/go-faker/faker/v4 v4.9.0
|
github.com/go-faker/faker/v4 v4.10.1
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.2
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||||
github.com/moby/docker-image-spec v1.3.1
|
github.com/moby/docker-image-spec v1.3.1
|
||||||
github.com/moby/moby/api v1.55.0
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/moby/moby/client v0.5.0
|
github.com/moby/moby/client v0.5.1
|
||||||
github.com/puzpuzpuz/xsync/v4 v4.5.0
|
github.com/puzpuzpuz/xsync/v4 v4.5.0
|
||||||
github.com/rs/zerolog v1.35.1
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/samber/lo v1.53.0
|
github.com/samber/lo v1.53.0
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.5
|
||||||
go.yaml.in/yaml/v3 v3.0.4
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/sync v0.22.0
|
golang.org/x/sync v0.22.0
|
||||||
google.golang.org/grpc v1.82.0
|
google.golang.org/grpc v1.83.0
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
||||||
k8s.io/api v0.36.2
|
k8s.io/api v0.36.3
|
||||||
k8s.io/apimachinery v0.36.2
|
k8s.io/apimachinery v0.36.3
|
||||||
k8s.io/client-go v0.36.2
|
k8s.io/client-go v0.36.3
|
||||||
k8s.io/metrics v0.36.2
|
k8s.io/metrics v0.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/modelcontextprotocol/go-sdk v1.6.1
|
require (
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
|
golang.org/x/term v0.45.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/air-verse/air v1.65.3 // indirect
|
github.com/air-verse/air v1.67.4 // indirect
|
||||||
github.com/alexflint/go-scalar v1.2.0 // indirect
|
github.com/alexflint/go-scalar v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||||
github.com/bep/golibsass v1.2.0 // indirect
|
github.com/bep/golibsass v1.2.0 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.5 // indirect
|
||||||
github.com/buger/jsonparser v1.2.0 // indirect
|
github.com/buger/jsonparser v1.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
@@ -96,7 +99,7 @@ require (
|
|||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||||
github.com/gohugoio/hugo v0.161.0 // indirect
|
github.com/gohugoio/hugo v0.164.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
@@ -122,7 +125,7 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
@@ -141,23 +144,22 @@ require (
|
|||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect
|
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
k8s.io/klog/v2 v2.140.0 // indirect
|
k8s.io/klog/v2 v2.140.0 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // indirect
|
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // indirect
|
||||||
k8s.io/streaming v0.36.2 // indirect
|
k8s.io/streaming v0.36.3 // indirect
|
||||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
|
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67
|
|||||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||||
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
|
github.com/air-verse/air v1.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
|
||||||
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
|
||||||
github.com/air-verse/air v1.65.2/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
|
||||||
github.com/air-verse/air v1.65.3/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
github.com/air-verse/air v1.65.3/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||||
|
github.com/air-verse/air v1.67.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||||
|
github.com/air-verse/air v1.67.2/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||||
|
github.com/air-verse/air v1.67.3/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||||
|
github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||||
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
|
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
|
||||||
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
|
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
|
||||||
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
|
||||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
@@ -71,6 +71,7 @@ github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
|
|||||||
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
|
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
@@ -83,14 +84,10 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK
|
|||||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/compose-spec/compose-go/v2 v2.10.2 h1:USa1NUbDcl/cjb8T9iwnuFsnO79H+2ho2L5SjFKz3uI=
|
|
||||||
github.com/compose-spec/compose-go/v2 v2.10.2/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
|
||||||
github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM=
|
|
||||||
github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
|
||||||
github.com/compose-spec/compose-go/v2 v2.12.1 h1:+xBZNxcgSus4atQJwXPEdhHRgCEyZmj/BuqN5m33Ou0=
|
|
||||||
github.com/compose-spec/compose-go/v2 v2.12.1/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
|
||||||
github.com/compose-spec/compose-go/v2 v2.13.0 h1:2+2oS3v4SrtAOBdZRAZYBsBy47D571p5EXMSCppmTtE=
|
github.com/compose-spec/compose-go/v2 v2.13.0 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.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 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
@@ -138,30 +135,26 @@ github.com/getkin/kin-openapi v0.137.0 h1:Q3HhawNQV0GfvO2mIYMUBUSEFrDsVlzcYz4Vyd
|
|||||||
github.com/getkin/kin-openapi v0.137.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
github.com/getkin/kin-openapi v0.137.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
|
||||||
github.com/go-chi/chi/v5 v5.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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||||
github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E=
|
|
||||||
github.com/go-faker/faker/v4 v4.7.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
|
||||||
github.com/go-faker/faker/v4 v4.8.0 h1:QAKmb4TyAMxIgf3a8fXubQXwFFJviBGL2/IDa34N6JQ=
|
|
||||||
github.com/go-faker/faker/v4 v4.8.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
|
||||||
github.com/go-faker/faker/v4 v4.9.0 h1:a4HXLwueuTCtgF93VpUsl8Zd2nG1VH2SgNWDPVEBg5U=
|
github.com/go-faker/faker/v4 v4.9.0 h1:a4HXLwueuTCtgF93VpUsl8Zd2nG1VH2SgNWDPVEBg5U=
|
||||||
github.com/go-faker/faker/v4 v4.9.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
github.com/go-faker/faker/v4 v4.9.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
||||||
|
github.com/go-faker/faker/v4 v4.10.0 h1:rHTZVwG1x8aN4zXDYJUFucOGDQojuxOby5MTdC/M2Fw=
|
||||||
|
github.com/go-faker/faker/v4 v4.10.0/go.mod h1:X+KzPB4JZ82GY4MYr7NV7zmp+i/K0SG5EDKqIg5zd1k=
|
||||||
|
github.com/go-faker/faker/v4 v4.10.1 h1:mGH3y0X8AfrXsfgPsPuTkWBYIebomvdkWEN1uDLoFFg=
|
||||||
|
github.com/go-faker/faker/v4 v4.10.1/go.mod h1:VFIEwWDd16EdYDLF6NJ5gAAzEp7vz5LgKgJ2iZ17Tdg=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
|
|
||||||
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
|
||||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||||
|
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
||||||
|
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -223,6 +216,7 @@ github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/Sfico
|
|||||||
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||||
github.com/gohugoio/hugo v0.161.0 h1:RnVIwSeTVDbhXbSwa+5C8QFkQVa634IdmekvSjLVMmI=
|
github.com/gohugoio/hugo v0.161.0 h1:RnVIwSeTVDbhXbSwa+5C8QFkQVa634IdmekvSjLVMmI=
|
||||||
github.com/gohugoio/hugo v0.161.0/go.mod h1:ZJStxHMZXnnhvCfOAy6FCLbWf90zTpH/cnvWAcmoyiE=
|
github.com/gohugoio/hugo v0.161.0/go.mod h1:ZJStxHMZXnnhvCfOAy6FCLbWf90zTpH/cnvWAcmoyiE=
|
||||||
|
github.com/gohugoio/hugo v0.164.0/go.mod h1:QPBCN6Gntmm6YrYJEnTasaZjnMLRkv8YoyCOw7mo9JE=
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
|
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
|
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
|
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
|
||||||
@@ -309,20 +303,18 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
|
|||||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
|
||||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
|
||||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
|
||||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
|
||||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||||
|
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||||
|
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||||
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
||||||
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -361,6 +353,7 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
|||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
@@ -432,8 +425,10 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
|||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||||
|
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
@@ -442,18 +437,26 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8V
|
|||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -463,12 +466,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
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 h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
@@ -480,8 +477,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
@@ -492,10 +489,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
@@ -507,10 +500,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -528,12 +517,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
@@ -545,10 +528,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -561,10 +540,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
@@ -575,21 +550,22 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
|
||||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
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.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110=
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc=
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
|
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||||
|
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
|
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM=
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8=
|
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
@@ -610,40 +586,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
|
|
||||||
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
|
|
||||||
k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
|
|
||||||
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
|
|
||||||
k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
|
k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
|
||||||
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
|
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
|
||||||
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
|
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
|
||||||
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
|
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
|
||||||
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
|
|
||||||
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
|
|
||||||
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
|
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
|
||||||
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
|
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
|
||||||
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
|
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
|
||||||
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
|
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
|
||||||
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
|
|
||||||
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
|
|
||||||
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
|
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
|
||||||
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
|
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
|
||||||
|
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
|
||||||
|
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
|
||||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ=
|
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ=
|
||||||
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
|
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
|
||||||
k8s.io/metrics v0.36.0 h1:VF41Mv9ZWKKQ4jEiJ0n3Tp6jdyO+oM6dbKcJn6Y/DVg=
|
|
||||||
k8s.io/metrics v0.36.0/go.mod h1:FY1dgPJZqnSfnOYbVdBEdRNUdy0n1nUCU6yxSMUrVG4=
|
|
||||||
k8s.io/metrics v0.36.1 h1:MQPb+G4RhrKEpt8NETPssbW8QgGUc4Jbqu1jx+kPqGk=
|
|
||||||
k8s.io/metrics v0.36.1/go.mod h1:xqS8XcWLjDzo6E7DJm/GfjKpRKdN5/MtJAQFuV6nLUc=
|
|
||||||
k8s.io/metrics v0.36.2 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8=
|
k8s.io/metrics v0.36.2 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8=
|
||||||
k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0=
|
k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0=
|
||||||
k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4=
|
k8s.io/metrics v0.36.3 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ=
|
||||||
k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM=
|
||||||
k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4=
|
|
||||||
k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
|
||||||
k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
|
k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
|
||||||
k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||||
|
k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
|
||||||
|
k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
|
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
|
||||||
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type contextKey string
|
|||||||
|
|
||||||
const remoteUser contextKey = "remoteUser"
|
const remoteUser contextKey = "remoteUser"
|
||||||
|
|
||||||
|
// WithUser returns a copy of ctx carrying user so downstream handlers can
|
||||||
|
// resolve it via UserFromContext.
|
||||||
|
func WithUser(ctx context.Context, user User) context.Context {
|
||||||
|
return context.WithValue(ctx, remoteUser, user)
|
||||||
|
}
|
||||||
|
|
||||||
type proxyAuthContext struct {
|
type proxyAuthContext struct {
|
||||||
headerUser string
|
headerUser string
|
||||||
headerEmail string
|
headerEmail string
|
||||||
@@ -55,7 +61,7 @@ func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler {
|
|||||||
userRoles = ParseRole(r.Header.Get(p.headerRoles))
|
userRoles = ParseRole(r.Header.Get(p.headerRoles))
|
||||||
}
|
}
|
||||||
user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter, userRoles)
|
user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter, userRoles)
|
||||||
ctx := context.WithValue(r.Context(), remoteUser, user)
|
ctx := WithUser(r.Context(), user)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
} else {
|
} else {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
@@ -18,8 +20,13 @@ type simpleAuthContext struct {
|
|||||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
|
||||||
func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext {
|
func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext {
|
||||||
|
// Hash the users in a stable order. Ranging over the map directly makes the
|
||||||
|
// digest depend on Go's randomized map iteration order, so any users.yml with
|
||||||
|
// more than one user derives a different signing key on every start and
|
||||||
|
// silently invalidates every session on restart.
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
for _, user := range userDatabase.Users {
|
for _, username := range slices.Sorted(maps.Keys(userDatabase.Users)) {
|
||||||
|
user := userDatabase.Users[username]
|
||||||
h.Write([]byte(user.Password))
|
h.Write([]byte(user.Password))
|
||||||
h.Write([]byte(user.RolesConfigured))
|
h.Write([]byte(user.RolesConfigured))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -448,14 +448,21 @@ func (s *ContainerStore) init() {
|
|||||||
|
|
||||||
case "rename":
|
case "rename":
|
||||||
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
|
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
|
||||||
if loaded {
|
if !loaded {
|
||||||
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
|
|
||||||
copy := *c
|
|
||||||
copy.Name = event.ActorAttributes["name"]
|
|
||||||
return ©, xsync.UpdateOp
|
|
||||||
} else {
|
|
||||||
return c, xsync.CancelOp
|
return c, xsync.CancelOp
|
||||||
}
|
}
|
||||||
|
// A dev.dozzle.name or coolify.serviceName label pins a custom
|
||||||
|
// display name (see newContainer). That name must survive a
|
||||||
|
// docker-level rename, so only follow the rename when the name
|
||||||
|
// actually comes from Docker.
|
||||||
|
if c.Labels["dev.dozzle.name"] != "" || c.Labels["coolify.serviceName"] != "" {
|
||||||
|
log.Debug().Str("id", event.ActorID).Msg("ignoring rename: container has a custom name label")
|
||||||
|
return c, xsync.CancelOp
|
||||||
|
}
|
||||||
|
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
|
||||||
|
copy := *c
|
||||||
|
copy.Name = event.ActorAttributes["name"]
|
||||||
|
return ©, xsync.UpdateOp
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
|
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
|
||||||
|
|||||||
@@ -119,6 +119,80 @@ func TestContainerStore_die(t *testing.T) {
|
|||||||
assert.Equal(t, containers[0].State, "exited")
|
assert.Equal(t, containers[0].State, "exited")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainerStore_rename(t *testing.T) {
|
||||||
|
run := func(t *testing.T, initial Container, attributes map[string]string) Container {
|
||||||
|
client := new(mockedClient)
|
||||||
|
client.On("ListContainers", mock.Anything, mock.Anything).Return([]Container{initial}, nil)
|
||||||
|
client.On("FindContainer", mock.Anything, initial.ID).Return(initial, nil)
|
||||||
|
client.On("Host").Return(Host{ID: "localhost"})
|
||||||
|
|
||||||
|
ready := make(chan struct{})
|
||||||
|
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).
|
||||||
|
Run(func(args mock.Arguments) {
|
||||||
|
ctx := args.Get(0).(context.Context)
|
||||||
|
events := args.Get(1).(chan<- ContainerEvent)
|
||||||
|
<-ready
|
||||||
|
events <- ContainerEvent{
|
||||||
|
Name: "rename",
|
||||||
|
ActorID: initial.ID,
|
||||||
|
Host: "localhost",
|
||||||
|
ActorAttributes: attributes,
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewContainerStore(t.Context(), client, &fakeStatsCollector{}, ContainerLabels{})
|
||||||
|
|
||||||
|
events := make(chan ContainerEvent)
|
||||||
|
store.SubscribeEvents(t.Context(), events)
|
||||||
|
close(ready)
|
||||||
|
<-events
|
||||||
|
|
||||||
|
containers, err := store.ListContainers(ContainerLabels{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, containers, 1)
|
||||||
|
return containers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("keeps custom name from dev.dozzle.name label", func(t *testing.T) {
|
||||||
|
initial := Container{
|
||||||
|
ID: "1234",
|
||||||
|
Name: "custom-name",
|
||||||
|
State: "running",
|
||||||
|
FullyLoaded: true,
|
||||||
|
Labels: map[string]string{"dev.dozzle.name": "custom-name"},
|
||||||
|
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||||
|
}
|
||||||
|
result := run(t, initial, map[string]string{"name": "new-docker-name"})
|
||||||
|
assert.Equal(t, "custom-name", result.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps custom name from coolify.serviceName label", func(t *testing.T) {
|
||||||
|
initial := Container{
|
||||||
|
ID: "1234",
|
||||||
|
Name: "coolify-name",
|
||||||
|
State: "running",
|
||||||
|
FullyLoaded: true,
|
||||||
|
Labels: map[string]string{"coolify.serviceName": "coolify-name"},
|
||||||
|
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||||
|
}
|
||||||
|
result := run(t, initial, map[string]string{"name": "new-docker-name"})
|
||||||
|
assert.Equal(t, "coolify-name", result.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("follows rename when name comes from docker", func(t *testing.T) {
|
||||||
|
initial := Container{
|
||||||
|
ID: "1234",
|
||||||
|
Name: "old-docker-name",
|
||||||
|
State: "running",
|
||||||
|
FullyLoaded: true,
|
||||||
|
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||||
|
}
|
||||||
|
result := run(t, initial, map[string]string{"name": "new-docker-name"})
|
||||||
|
assert.Equal(t, "new-docker-name", result.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type fakeStatsCollector struct{}
|
type fakeStatsCollector struct{}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) Subscribe(_ context.Context, _ chan<- ContainerStat) {}
|
func (f *fakeStatsCollector) Subscribe(_ context.Context, _ chan<- ContainerStat) {}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ var SupportedLogLevels map[string]struct{}
|
|||||||
var logLevels = [][]string{
|
var logLevels = [][]string{
|
||||||
{"error", "err"},
|
{"error", "err"},
|
||||||
{"warn", "warning", "wrn"},
|
{"warn", "warning", "wrn"},
|
||||||
{"info", "inf"},
|
{"info", "inf", "information"},
|
||||||
{"debug", "dbg"},
|
{"debug", "dbg"},
|
||||||
{"trace", "verbose", "ver", "vbs"},
|
{"trace", "verbose", "ver", "vbs"},
|
||||||
{"fatal", "sev", "severe", "crit", "critical"},
|
{"fatal", "sev", "severe", "crit", "critical"},
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ func TestGuessLogLevel(t *testing.T) {
|
|||||||
{"WARN: connection error: retrying", "warn"},
|
{"WARN: connection error: retrying", "warn"},
|
||||||
// Symmetric: an ERROR prefix still wins over a later info word.
|
// Symmetric: an ERROR prefix still wins over a later info word.
|
||||||
{"ERROR: handler failed, info: will retry", "error"},
|
{"ERROR: handler failed, info: will retry", "error"},
|
||||||
|
// .NET / Serilog / Microsoft.Extensions.Logging spell out "Information".
|
||||||
|
{"Information: service started", "info"},
|
||||||
|
{"[Information] service started", "info"},
|
||||||
|
{"2024-12-30T17:43:16Z Information starting up", "info"},
|
||||||
|
{orderedmap.New[string, string](
|
||||||
|
orderedmap.WithInitialData(
|
||||||
|
orderedmap.Pair[string, string]{Key: "level", Value: "Information"},
|
||||||
|
),
|
||||||
|
), "info"},
|
||||||
|
{orderedmap.New[string, any](
|
||||||
|
orderedmap.WithInitialData(
|
||||||
|
orderedmap.Pair[string, any]{Key: "@l", Value: "Information"},
|
||||||
|
),
|
||||||
|
), "info"},
|
||||||
// Equal confidence between two different levels -> unknown (don't guess).
|
// Equal confidence between two different levels -> unknown (don't guess).
|
||||||
{"saw info: here and error: there", "unknown"},
|
{"saw info: here and error: there", "unknown"},
|
||||||
{"[INFO] [DEBUG] both bracketed", "unknown"},
|
{"[INFO] [DEBUG] both bracketed", "unknown"},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||||
)
|
)
|
||||||
@@ -36,7 +38,7 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
|||||||
} else if char == '\\' {
|
} else if char == '\\' {
|
||||||
escaping = true
|
escaping = true
|
||||||
} else if char == '"' {
|
} else if char == '"' {
|
||||||
value = log[start:i]
|
value = unescapeQuoted(log[start-1 : i+1])
|
||||||
result.Set(key, value)
|
result.Set(key, value)
|
||||||
inQuotes = false
|
inQuotes = false
|
||||||
isKey = true
|
isKey = true
|
||||||
@@ -77,3 +79,19 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unescapeQuoted decodes a quoted logfmt value (including the surrounding
|
||||||
|
// quotes) so escape sequences like \" and \\ produced by logfmt encoders
|
||||||
|
// (logrus, go-kit, go-logfmt) yield the original text instead of leaking
|
||||||
|
// backslashes into the parsed value. If the sequence is not decodable, the
|
||||||
|
// raw content between the quotes is returned unchanged.
|
||||||
|
func unescapeQuoted(quoted string) string {
|
||||||
|
raw := quoted[1 : len(quoted)-1]
|
||||||
|
if !strings.ContainsRune(raw, '\\') {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if unquoted, err := strconv.Unquote(quoted); err == nil {
|
||||||
|
return unquoted
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,28 @@ func TestParseLog(t *testing.T) {
|
|||||||
),
|
),
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Escaped quotes and backslashes in quoted values",
|
||||||
|
log: `level=info msg="failed to open \"config.yml\"" path="C:\\temp"`,
|
||||||
|
want: orderedmap.New[string, string](
|
||||||
|
orderedmap.WithInitialData(
|
||||||
|
orderedmap.Pair[string, string]{Key: "level", Value: "info"},
|
||||||
|
orderedmap.Pair[string, string]{Key: "msg", Value: `failed to open "config.yml"`},
|
||||||
|
orderedmap.Pair[string, string]{Key: "path", Value: `C:\temp`},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Quoted value with undecodable escape is kept as-is",
|
||||||
|
log: `msg="foo \q bar"`,
|
||||||
|
want: orderedmap.New[string, string](
|
||||||
|
orderedmap.WithInitialData(
|
||||||
|
orderedmap.Pair[string, string]{Key: "msg", Value: `foo \q bar`},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Broken format with unexpected quotes",
|
name: "Broken format with unexpected quotes",
|
||||||
log: `key1=value"1"= key2="value2"`,
|
log: `key1=value"1"= key2="value2"`,
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ func NewLogReader(r io.Reader, tty bool) *LogReader {
|
|||||||
|
|
||||||
func (d *LogReader) Read() (string, container.StdType, error) {
|
func (d *LogReader) Read() (string, container.StdType, error) {
|
||||||
message, stdType, err := d.readEvent()
|
message, stdType, err := d.readEvent()
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var std container.StdType
|
var std container.StdType
|
||||||
switch stdType {
|
switch stdType {
|
||||||
@@ -53,10 +50,18 @@ func (d *LogReader) Read() (string, container.StdType, error) {
|
|||||||
std = container.STDERR
|
std = container.STDERR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A final line without a trailing newline (e.g. a container's last write
|
||||||
|
// before exiting) arrives together with EOF. Return the partial message
|
||||||
|
// with the error instead of dropping it; the event generator emits the
|
||||||
|
// message before handling the error.
|
||||||
|
if err != nil {
|
||||||
|
return message, std, err
|
||||||
|
}
|
||||||
|
|
||||||
for !strings.HasSuffix(message, "\n") {
|
for !strings.HasSuffix(message, "\n") {
|
||||||
tail, _, err := d.readEvent()
|
tail, _, err := d.readEvent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", std, err
|
return message, std, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, after, _ := strings.Cut(tail, " ")
|
_, after, _ := strings.Cut(tail, " ")
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/container"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFrame(buf *bytes.Buffer, streamType byte, payload string) {
|
||||||
|
header := make([]byte, 8)
|
||||||
|
header[0] = streamType
|
||||||
|
binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
|
||||||
|
buf.Write(header)
|
||||||
|
buf.WriteString(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_multiplexed(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z out\n")
|
||||||
|
writeFrame(buf, 2, "2024-01-01T00:00:01.000000000Z err\n")
|
||||||
|
|
||||||
|
reader := NewLogReader(buf, false)
|
||||||
|
|
||||||
|
message, std, err := reader.Read()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:00.000000000Z out\n", message)
|
||||||
|
assert.Equal(t, container.STDOUT, std)
|
||||||
|
|
||||||
|
message, std, err = reader.Read()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:01.000000000Z err\n", message)
|
||||||
|
assert.Equal(t, container.STDERR, std)
|
||||||
|
|
||||||
|
_, _, err = reader.Read()
|
||||||
|
assert.Equal(t, io.EOF, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_lastLineWithoutNewline(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z first\n")
|
||||||
|
writeFrame(buf, 1, "2024-01-01T00:00:01.000000000Z last line without newline")
|
||||||
|
|
||||||
|
reader := NewLogReader(buf, false)
|
||||||
|
|
||||||
|
message, _, err := reader.Read()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message)
|
||||||
|
|
||||||
|
message, std, err := reader.Read()
|
||||||
|
assert.Equal(t, io.EOF, err)
|
||||||
|
assert.Equal(t, container.STDOUT, std)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_lastLineWithoutNewlineTTY(t *testing.T) {
|
||||||
|
input := "2024-01-01T00:00:00.000000000Z first\n2024-01-01T00:00:01.000000000Z last line without newline"
|
||||||
|
reader := NewLogReader(strings.NewReader(input), true)
|
||||||
|
|
||||||
|
message, _, err := reader.Read()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message)
|
||||||
|
|
||||||
|
message, _, err = reader.Read()
|
||||||
|
assert.Equal(t, io.EOF, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_continuedFrames(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z part one ")
|
||||||
|
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z and part two\n")
|
||||||
|
|
||||||
|
reader := NewLogReader(buf, false)
|
||||||
|
|
||||||
|
message, _, err := reader.Read()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:00.000000000Z part one and part two\n", message)
|
||||||
|
}
|
||||||
@@ -18,10 +18,9 @@ func NewLogReader(reader io.ReadCloser) *LogReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogReader) Read() (string, container.StdType, error) {
|
func (r *LogReader) Read() (string, container.StdType, error) {
|
||||||
|
// A final line without a trailing newline arrives together with EOF.
|
||||||
|
// Return the partial line with the error instead of dropping it; the
|
||||||
|
// event generator emits the message before handling the error.
|
||||||
line, err := r.reader.ReadString('\n')
|
line, err := r.reader.ReadString('\n')
|
||||||
if err != nil {
|
return line, container.STDOUT, err
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return line, container.STDOUT, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRead_lastLineWithoutNewline(t *testing.T) {
|
||||||
|
input := "2024-01-01T00:00:00.000000000Z first\n2024-01-01T00:00:01.000000000Z last line without newline"
|
||||||
|
reader := NewLogReader(io.NopCloser(strings.NewReader(input)))
|
||||||
|
|
||||||
|
message, _, err := reader.Read()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message)
|
||||||
|
|
||||||
|
message, _, err = reader.Read()
|
||||||
|
assert.Equal(t, io.EOF, err)
|
||||||
|
assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
"github.com/amir20/dozzle/internal/container"
|
"github.com/amir20/dozzle/internal/container"
|
||||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -48,6 +49,19 @@ func NewServer(hostService HostService, labels container.ContainerLabels, versio
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLabels returns the container label filter to apply for the requesting
|
||||||
|
// user. When authentication is enabled the user's own filter is carried on the
|
||||||
|
// request context; using it (instead of the server-global labels) keeps MCP
|
||||||
|
// reads scoped to the same containers the user can see through the normal log
|
||||||
|
// and stats paths. Falls back to the server-global labels when no per-user
|
||||||
|
// filter is present (e.g. auth disabled).
|
||||||
|
func (s *Server) resolveLabels(ctx context.Context) container.ContainerLabels {
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil && user.ContainerLabels.Exists() {
|
||||||
|
return user.ContainerLabels
|
||||||
|
}
|
||||||
|
return s.labels
|
||||||
|
}
|
||||||
|
|
||||||
// Handler returns an http.Handler for the MCP streamable HTTP transport.
|
// Handler returns an http.Handler for the MCP streamable HTTP transport.
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||||
@@ -68,6 +82,15 @@ type getContainerLogsParams struct {
|
|||||||
Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to read: stdout, stderr, or all. Defaults to all."`
|
Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to read: stdout, stderr, or all. Defaults to all."`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type searchContainerLogsParams struct {
|
||||||
|
Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."`
|
||||||
|
ContainerID string `json:"container_id" jsonschema:"The container ID (or short ID) to search logs from. Use list_containers to find this."`
|
||||||
|
Query string `json:"query" jsonschema:"The search string to look for in log messages. Case-insensitive by default."`
|
||||||
|
SinceMinutes *int `json:"since_minutes,omitempty" jsonschema:"Search logs from the last N minutes. Defaults to 5."`
|
||||||
|
Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to search: stdout, stderr, or all. Defaults to all."`
|
||||||
|
CaseSensitive *bool `json:"case_sensitive,omitempty" jsonschema:"Whether to perform a case-sensitive search. Defaults to false."`
|
||||||
|
}
|
||||||
|
|
||||||
type getContainerStatsParams struct {
|
type getContainerStatsParams struct {
|
||||||
Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."`
|
Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."`
|
||||||
ContainerID string `json:"container_id" jsonschema:"The container ID to get stats for. Use list_containers to find this."`
|
ContainerID string `json:"container_id" jsonschema:"The container ID to get stats for. Use list_containers to find this."`
|
||||||
@@ -86,6 +109,12 @@ func (s *Server) registerTools() {
|
|||||||
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
||||||
}, s.handleGetContainerLogs)
|
}, s.handleGetContainerLogs)
|
||||||
|
|
||||||
|
mcp.AddTool(s.mcpServer, &mcp.Tool{
|
||||||
|
Name: "search_container_logs",
|
||||||
|
Description: "Search container logs for a keyword or phrase. Returns only matching log entries, making it efficient for finding specific errors or events without downloading large volumes of logs.",
|
||||||
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
||||||
|
}, s.handleSearchContainerLogs)
|
||||||
|
|
||||||
mcp.AddTool(s.mcpServer, &mcp.Tool{
|
mcp.AddTool(s.mcpServer, &mcp.Tool{
|
||||||
Name: "list_hosts",
|
Name: "list_hosts",
|
||||||
Description: "List all Docker hosts connected to Dozzle.",
|
Description: "List all Docker hosts connected to Dozzle.",
|
||||||
@@ -102,7 +131,7 @@ func (s *Server) registerTools() {
|
|||||||
// --- Tool Handlers ---
|
// --- Tool Handlers ---
|
||||||
|
|
||||||
func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolRequest, params *listContainersParams) (*mcp.CallToolResult, any, error) {
|
func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolRequest, params *listContainersParams) (*mcp.CallToolResult, any, error) {
|
||||||
containers, errs := s.hostService.ListAllContainers(s.labels)
|
containers, errs := s.hostService.ListAllContainers(s.resolveLabels(ctx))
|
||||||
for _, err := range errs {
|
for _, err := range errs {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("partial failure listing containers from a host")
|
log.Warn().Err(err).Msg("partial failure listing containers from a host")
|
||||||
@@ -154,95 +183,119 @@ func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolReques
|
|||||||
}, nil, nil
|
}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) {
|
const maxLogSize = 1024 * 1024 // 1MB limit
|
||||||
if params.Host == "" || params.ContainerID == "" {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "host and container_id are required"}},
|
|
||||||
IsError: true,
|
|
||||||
}, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels)
|
// mcpLogEntry is the JSON shape returned by the log tools.
|
||||||
if err != nil {
|
type mcpLogEntry struct {
|
||||||
return &mcp.CallToolResult{
|
Timestamp string `json:"timestamp"`
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
|
Level string `json:"level,omitempty"`
|
||||||
IsError: true,
|
Stream string `json:"stream,omitempty"`
|
||||||
}, nil, nil
|
Type string `json:"type"`
|
||||||
}
|
Message any `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
stream := ""
|
func errorResult(text string) *mcp.CallToolResult {
|
||||||
if params.Stream != nil {
|
return &mcp.CallToolResult{
|
||||||
stream = *params.Stream
|
Content: []mcp.Content{&mcp.TextContent{Text: text}},
|
||||||
|
IsError: true,
|
||||||
}
|
}
|
||||||
var stdType container.StdType
|
}
|
||||||
switch stream {
|
|
||||||
|
func textResult(text string) *mcp.CallToolResult {
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: text}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStream resolves the optional stream parameter into a StdType. It returns
|
||||||
|
// a non-nil error result when the value is invalid.
|
||||||
|
func parseStream(stream *string) (container.StdType, *mcp.CallToolResult) {
|
||||||
|
value := ""
|
||||||
|
if stream != nil {
|
||||||
|
value = *stream
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
case "", "all":
|
case "", "all":
|
||||||
stdType = container.STDALL
|
return container.STDALL, nil
|
||||||
case "stdout":
|
case "stdout":
|
||||||
stdType = container.STDOUT
|
return container.STDOUT, nil
|
||||||
case "stderr":
|
case "stderr":
|
||||||
stdType = container.STDERR
|
return container.STDERR, nil
|
||||||
default:
|
default:
|
||||||
return &mcp.CallToolResult{
|
return container.STDALL, errorResult(fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", value))
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", stream)}},
|
}
|
||||||
IsError: true,
|
}
|
||||||
}, nil, nil
|
|
||||||
|
// fetchLogs finds the container and returns its log events for the last
|
||||||
|
// sinceMinutes (defaulting to 5). On a user-facing failure it returns a non-nil
|
||||||
|
// error result; otherwise the caller must call cancel when done.
|
||||||
|
func (s *Server) fetchLogs(ctx context.Context, host, containerID string, stream *string, sinceMinutes *int) (<-chan *container.LogEvent, context.CancelFunc, *mcp.CallToolResult) {
|
||||||
|
containerSvc, err := s.hostService.FindContainer(host, containerID, s.resolveLabels(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errorResult(fmt.Sprintf("container not found: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
sinceMinutes := 5
|
stdType, errResult := parseStream(stream)
|
||||||
if params.SinceMinutes != nil && *params.SinceMinutes > 0 {
|
if errResult != nil {
|
||||||
sinceMinutes = *params.SinceMinutes
|
return nil, nil, errResult
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := 5
|
||||||
|
if sinceMinutes != nil && *sinceMinutes > 0 {
|
||||||
|
minutes = *sinceMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
logCtx, cancel := context.WithCancel(ctx)
|
logCtx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
since := time.Now().Add(-time.Duration(minutes) * time.Minute)
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(sinceMinutes) * time.Minute)
|
|
||||||
events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType)
|
events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &mcp.CallToolResult{
|
cancel()
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("failed to read logs: %v", err)}},
|
return nil, nil, errorResult(fmt.Sprintf("failed to read logs: %v", err))
|
||||||
IsError: true,
|
|
||||||
}, nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type logEntry struct {
|
return events, cancel, nil
|
||||||
Timestamp string `json:"timestamp"`
|
}
|
||||||
Level string `json:"level,omitempty"`
|
|
||||||
Stream string `json:"stream,omitempty"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message any `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []logEntry
|
// eventMessage extracts the JSON-encodable message payload for a log event.
|
||||||
totalSize := 0
|
func eventMessage(event *container.LogEvent) any {
|
||||||
const maxSize = 1024 * 1024 // 1MB limit
|
switch event.Type {
|
||||||
|
case container.LogTypeGroup:
|
||||||
for event := range events {
|
if fragments, ok := event.Message.([]container.LogFragment); ok {
|
||||||
var msg any
|
lines := make([]string, len(fragments))
|
||||||
switch event.Type {
|
for i, f := range fragments {
|
||||||
case container.LogTypeGroup:
|
lines[i] = f.Message
|
||||||
if fragments, ok := event.Message.([]container.LogFragment); ok {
|
|
||||||
lines := make([]string, len(fragments))
|
|
||||||
for i, f := range fragments {
|
|
||||||
lines[i] = f.Message
|
|
||||||
}
|
|
||||||
msg = lines
|
|
||||||
} else {
|
|
||||||
msg = event.RawMessage
|
|
||||||
}
|
}
|
||||||
case container.LogTypeComplex:
|
return lines
|
||||||
msg = event.Message
|
|
||||||
default:
|
|
||||||
msg = event.RawMessage
|
|
||||||
}
|
}
|
||||||
|
return event.RawMessage
|
||||||
|
case container.LogTypeComplex:
|
||||||
|
return event.Message
|
||||||
|
default:
|
||||||
|
return event.RawMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry := logEntry{
|
func newLogEntry(event *container.LogEvent) mcpLogEntry {
|
||||||
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
|
return mcpLogEntry{
|
||||||
Level: event.Level,
|
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
|
||||||
Stream: event.Stream,
|
Level: event.Level,
|
||||||
Type: string(event.Type),
|
Stream: event.Stream,
|
||||||
Message: msg,
|
Type: string(event.Type),
|
||||||
|
Message: eventMessage(event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectLogEntries drains events into JSON-encodable entries. keep, when
|
||||||
|
// non-nil, filters which entries are included. Collection stops once the encoded
|
||||||
|
// size would exceed maxLogSize, in which case truncated is true. scanned counts
|
||||||
|
// every event pulled from the channel, matched or not.
|
||||||
|
func collectLogEntries(events <-chan *container.LogEvent, keep func(mcpLogEntry) bool) (entries []mcpLogEntry, scanned int, truncated bool) {
|
||||||
|
totalSize := 0
|
||||||
|
for event := range events {
|
||||||
|
scanned++
|
||||||
|
entry := newLogEntry(event)
|
||||||
|
if keep != nil && !keep(entry) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
line, err := json.Marshal(entry)
|
line, err := json.Marshal(entry)
|
||||||
@@ -251,30 +304,105 @@ func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalSize += len(line) + 1
|
totalSize += len(line) + 1
|
||||||
if totalSize > maxSize {
|
if totalSize > maxLogSize {
|
||||||
|
truncated = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
|
return entries, scanned, truncated
|
||||||
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
// encodeLogEntries renders entries as newline-delimited JSON.
|
||||||
return &mcp.CallToolResult{
|
func encodeLogEntries(entries []mcpLogEntry) (string, error) {
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: "(no logs in the specified time range)"}},
|
|
||||||
}, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
encoder := json.NewEncoder(&sb)
|
encoder := json.NewEncoder(&sb)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if err := encoder.Encode(entry); err != nil {
|
if err := encoder.Encode(entry); err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to encode log entry: %w", err)
|
return "", fmt.Errorf("failed to encode log entry: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return strings.TrimRight(sb.String(), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
return &mcp.CallToolResult{
|
func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) {
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: strings.TrimRight(sb.String(), "\n")}},
|
if params.Host == "" || params.ContainerID == "" {
|
||||||
}, nil, nil
|
return errorResult("host and container_id are required"), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
events, cancel, errResult := s.fetchLogs(ctx, params.Host, params.ContainerID, params.Stream, params.SinceMinutes)
|
||||||
|
if errResult != nil {
|
||||||
|
return errResult, nil, nil
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
entries, _, _ := collectLogEntries(events, nil)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return textResult("(no logs in the specified time range)"), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := encodeLogEntries(entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return textResult(text), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSearchContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *searchContainerLogsParams) (*mcp.CallToolResult, any, error) {
|
||||||
|
if params.Host == "" || params.ContainerID == "" || params.Query == "" {
|
||||||
|
return errorResult("host, container_id, and query are required"), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
caseSensitive := params.CaseSensitive != nil && *params.CaseSensitive
|
||||||
|
query := params.Query
|
||||||
|
if !caseSensitive {
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, cancel, errResult := s.fetchLogs(ctx, params.Host, params.ContainerID, params.Stream, params.SinceMinutes)
|
||||||
|
if errResult != nil {
|
||||||
|
return errResult, nil, nil
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
keep := func(entry mcpLogEntry) bool {
|
||||||
|
haystack := messageToSearchString(entry.Message)
|
||||||
|
if !caseSensitive {
|
||||||
|
haystack = strings.ToLower(haystack)
|
||||||
|
}
|
||||||
|
return strings.Contains(haystack, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, scanned, truncated := collectLogEntries(events, keep)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return textResult(fmt.Sprintf("(no matches for %q in %d log entries scanned)", params.Query, scanned)), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := encodeLogEntries(entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "Found %d matches for %q (scanned %d entries):\n", len(entries), params.Query, scanned)
|
||||||
|
sb.WriteString(body)
|
||||||
|
if truncated {
|
||||||
|
sb.WriteString("\n(results truncated at 1MB; narrow your query or time range to see more)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return textResult(sb.String()), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageToSearchString(msg any) string {
|
||||||
|
switch v := msg.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case []string:
|
||||||
|
return strings.Join(v, "\n")
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListHosts(ctx context.Context, _ *mcp.CallToolRequest, _ *struct{}) (*mcp.CallToolResult, any, error) {
|
func (s *Server) handleListHosts(ctx context.Context, _ *mcp.CallToolRequest, _ *struct{}) (*mcp.CallToolResult, any, error) {
|
||||||
@@ -321,7 +449,7 @@ func (s *Server) handleGetContainerStats(ctx context.Context, _ *mcp.CallToolReq
|
|||||||
}, nil, nil
|
}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels)
|
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.resolveLabels(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &mcp.CallToolResult{
|
return &mcp.CallToolResult{
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
|
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
"github.com/amir20/dozzle/internal/container"
|
"github.com/amir20/dozzle/internal/container"
|
||||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||||
"github.com/amir20/dozzle/internal/utils"
|
"github.com/amir20/dozzle/internal/utils"
|
||||||
@@ -22,6 +24,10 @@ type mockHostService struct {
|
|||||||
listErrs []error
|
listErrs []error
|
||||||
logEvents []*container.LogEvent
|
logEvents []*container.LogEvent
|
||||||
logErr error
|
logErr error
|
||||||
|
|
||||||
|
// gotLabels records the last label filter passed to a lookup so tests can
|
||||||
|
// assert the requesting user's filter is applied instead of the global one.
|
||||||
|
gotLabels container.ContainerLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
type stubClientService struct {
|
type stubClientService struct {
|
||||||
@@ -53,6 +59,7 @@ func (s *stubClientService) RawLogs(context.Context, container.Container, time.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockHostService) FindContainer(host string, id string, labels container.ContainerLabels) (*container_support.ContainerService, error) {
|
func (m *mockHostService) FindContainer(host string, id string, labels container.ContainerLabels) (*container_support.ContainerService, error) {
|
||||||
|
m.gotLabels = labels
|
||||||
if m.findErr != nil {
|
if m.findErr != nil {
|
||||||
return nil, m.findErr
|
return nil, m.findErr
|
||||||
}
|
}
|
||||||
@@ -66,6 +73,7 @@ func (m *mockHostService) FindContainer(host string, id string, labels container
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockHostService) ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error) {
|
func (m *mockHostService) ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error) {
|
||||||
|
m.gotLabels = labels
|
||||||
return m.containers, m.listErrs
|
return m.containers, m.listErrs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +81,35 @@ func (m *mockHostService) Hosts() []container.Host {
|
|||||||
return m.hosts
|
return m.hosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadToolsUseRequestingUsersFilter(t *testing.T) {
|
||||||
|
svc := &mockHostService{
|
||||||
|
containers: []container.Container{{ID: "abc123", Name: "web", Host: "local"}},
|
||||||
|
}
|
||||||
|
// Server-global filter. A restricted user must NOT read through this.
|
||||||
|
global := container.ContainerLabels{"com.example.scope": {"admin"}}
|
||||||
|
s := NewServer(svc, global, "test")
|
||||||
|
|
||||||
|
userLabels := container.ContainerLabels{"com.example.scope": {"tenant-a"}}
|
||||||
|
ctx := auth.WithUser(context.Background(), auth.User{ContainerLabels: userLabels})
|
||||||
|
|
||||||
|
// list_containers scopes to the user's filter.
|
||||||
|
_, _, err := s.handleListContainers(ctx, nil, &listContainersParams{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userLabels, svc.gotLabels, "list_containers should use the requesting user's filter")
|
||||||
|
|
||||||
|
// get_container_stats (FindContainer path) scopes to the user's filter.
|
||||||
|
svc.gotLabels = nil
|
||||||
|
_, _, err = s.handleGetContainerStats(ctx, nil, &getContainerStatsParams{Host: "local", ContainerID: "abc123"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userLabels, svc.gotLabels, "get_container_stats should use the requesting user's filter")
|
||||||
|
|
||||||
|
// With no user on the context (e.g. auth disabled) it falls back to the global filter.
|
||||||
|
svc.gotLabels = nil
|
||||||
|
_, _, err = s.handleListContainers(context.Background(), nil, &listContainersParams{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, global, svc.gotLabels, "falls back to server-global filter when unauthenticated")
|
||||||
|
}
|
||||||
|
|
||||||
func TestListContainers(t *testing.T) {
|
func TestListContainers(t *testing.T) {
|
||||||
svc := &mockHostService{
|
svc := &mockHostService{
|
||||||
containers: []container.Container{
|
containers: []container.Container{
|
||||||
@@ -292,9 +329,10 @@ func TestNewServerRegistersTools(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, toolNames, "list_containers")
|
assert.Contains(t, toolNames, "list_containers")
|
||||||
assert.Contains(t, toolNames, "get_container_logs")
|
assert.Contains(t, toolNames, "get_container_logs")
|
||||||
|
assert.Contains(t, toolNames, "search_container_logs")
|
||||||
assert.Contains(t, toolNames, "list_hosts")
|
assert.Contains(t, toolNames, "list_hosts")
|
||||||
assert.Contains(t, toolNames, "get_container_stats")
|
assert.Contains(t, toolNames, "get_container_stats")
|
||||||
assert.Len(t, tools.Tools, 4)
|
assert.Len(t, tools.Tools, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetContainerLogs(t *testing.T) {
|
func TestGetContainerLogs(t *testing.T) {
|
||||||
@@ -362,3 +400,175 @@ func TestGetContainerLogsInvalidStream(t *testing.T) {
|
|||||||
text := result.Content[0].(*mcp.TextContent).Text
|
text := result.Content[0].(*mcp.TextContent).Text
|
||||||
assert.Contains(t, text, "invalid stream")
|
assert.Contains(t, text, "invalid stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchContainerLogs(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
svc := &mockHostService{
|
||||||
|
containers: []container.Container{
|
||||||
|
{ID: "abc123", Name: "web", Host: "local"},
|
||||||
|
},
|
||||||
|
logEvents: []*container.LogEvent{
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Request received for /home"},
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "Failed to process payment"},
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Request received for /about"},
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "payment gateway timeout"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewServer(svc, nil, "test")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ct, st := mcp.NewInMemoryTransports()
|
||||||
|
|
||||||
|
_, err := s.mcpServer.Connect(ctx, st, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
||||||
|
session, err := client.Connect(ctx, ct, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "search_container_logs",
|
||||||
|
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "payment"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.IsError)
|
||||||
|
text := result.Content[0].(*mcp.TextContent).Text
|
||||||
|
assert.Contains(t, text, "Failed to process payment")
|
||||||
|
assert.Contains(t, text, "payment gateway timeout")
|
||||||
|
assert.NotContains(t, text, "Request received for /home")
|
||||||
|
assert.Contains(t, text, "Found 2 matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchContainerLogsCaseSensitive(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
svc := &mockHostService{
|
||||||
|
containers: []container.Container{
|
||||||
|
{ID: "abc123", Name: "web", Host: "local"},
|
||||||
|
},
|
||||||
|
logEvents: []*container.LogEvent{
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Error: something went wrong"},
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "error: lowercase message"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewServer(svc, nil, "test")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ct, st := mcp.NewInMemoryTransports()
|
||||||
|
|
||||||
|
_, err := s.mcpServer.Connect(ctx, st, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
||||||
|
session, err := client.Connect(ctx, ct, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "search_container_logs",
|
||||||
|
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "Error", "case_sensitive": true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.IsError)
|
||||||
|
text := result.Content[0].(*mcp.TextContent).Text
|
||||||
|
assert.Contains(t, text, "Error: something went wrong")
|
||||||
|
assert.NotContains(t, text, "error: lowercase message")
|
||||||
|
assert.Contains(t, text, "Found 1 match")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchContainerLogsNoMatches(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
svc := &mockHostService{
|
||||||
|
containers: []container.Container{
|
||||||
|
{ID: "abc123", Name: "web", Host: "local"},
|
||||||
|
},
|
||||||
|
logEvents: []*container.LogEvent{
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "everything is fine"},
|
||||||
|
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "all good here"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewServer(svc, nil, "test")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ct, st := mcp.NewInMemoryTransports()
|
||||||
|
|
||||||
|
_, err := s.mcpServer.Connect(ctx, st, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
||||||
|
session, err := client.Connect(ctx, ct, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "search_container_logs",
|
||||||
|
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "error"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.IsError)
|
||||||
|
text := result.Content[0].(*mcp.TextContent).Text
|
||||||
|
assert.Contains(t, text, "no matches")
|
||||||
|
assert.Contains(t, text, "2 log entries scanned")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchContainerLogsRequiredParams(t *testing.T) {
|
||||||
|
svc := &mockHostService{}
|
||||||
|
|
||||||
|
s := NewServer(svc, nil, "test")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ct, st := mcp.NewInMemoryTransports()
|
||||||
|
|
||||||
|
_, err := s.mcpServer.Connect(ctx, st, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
||||||
|
session, err := client.Connect(ctx, ct, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "search_container_logs",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchContainerLogsTruncates(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
big := strings.Repeat("match ", 200) // ~1.2KB per entry
|
||||||
|
events := make([]*container.LogEvent, 2000)
|
||||||
|
for i := range events {
|
||||||
|
events[i] = &container.LogEvent{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: big}
|
||||||
|
}
|
||||||
|
svc := &mockHostService{
|
||||||
|
containers: []container.Container{{ID: "abc123", Name: "web", Host: "local"}},
|
||||||
|
logEvents: events,
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewServer(svc, nil, "test")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ct, st := mcp.NewInMemoryTransports()
|
||||||
|
|
||||||
|
_, err := s.mcpServer.Connect(ctx, st, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
||||||
|
session, err := client.Connect(ctx, ct, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "search_container_logs",
|
||||||
|
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "match"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.IsError)
|
||||||
|
text := result.Content[0].(*mcp.TextContent).Text
|
||||||
|
assert.Contains(t, text, "results truncated")
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,6 +31,16 @@ var errBlockedAddress = errors.New("webhook target resolves to a blocked address
|
|||||||
var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}
|
var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}
|
||||||
|
|
||||||
func isBlockedIP(ip net.IP) bool {
|
func isBlockedIP(ip net.IP) bool {
|
||||||
|
if isBlockedBaseIP(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// IPv6 transition mechanisms (6to4, NAT64, Teredo, IPv4-compatible) embed an
|
||||||
|
// arbitrary IPv4 address that none of the checks above look at. Unwrap and
|
||||||
|
// re-check the embedded address so 2002:7f00:1::1 is treated as 127.0.0.1.
|
||||||
|
return slices.ContainsFunc(embeddedIPv4(ip), isBlockedBaseIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlockedBaseIP(ip net.IP) bool {
|
||||||
if ip.IsLoopback() ||
|
if ip.IsLoopback() ||
|
||||||
ip.IsLinkLocalUnicast() ||
|
ip.IsLinkLocalUnicast() ||
|
||||||
ip.IsLinkLocalMulticast() ||
|
ip.IsLinkLocalMulticast() ||
|
||||||
@@ -50,6 +61,59 @@ func isBlockedIP(ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embeddedIPv4 returns the IPv4 addresses carried inside an IPv6 transition
|
||||||
|
// address, or nil when the address carries none. Teredo yields two: the relay
|
||||||
|
// server and the (obfuscated) client.
|
||||||
|
func embeddedIPv4(ip net.IP) []net.IP {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ip16 := ip.To16()
|
||||||
|
if ip16 == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// 6to4 — RFC 3056, 2002::/16, IPv4 in bytes 2-6
|
||||||
|
case ip16[0] == 0x20 && ip16[1] == 0x02:
|
||||||
|
return []net.IP{net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])}
|
||||||
|
|
||||||
|
// NAT64 well-known prefix — RFC 6052, 64:ff9b::/96, IPv4 in the low 32 bits
|
||||||
|
case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b &&
|
||||||
|
isZeros(ip16[4:12]):
|
||||||
|
return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])}
|
||||||
|
|
||||||
|
// NAT64 local-use prefix — RFC 8215, 64:ff9b:1::/48. The embedded IPv4 sits
|
||||||
|
// at a position that depends on the operator's prefix length, so block the
|
||||||
|
// whole range rather than guess.
|
||||||
|
case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b && ip16[4] == 0x00 && ip16[5] == 0x01:
|
||||||
|
return []net.IP{net.IPv4zero}
|
||||||
|
|
||||||
|
// Teredo — RFC 4380, 2001::/32. Server IPv4 in bytes 4-8, client IPv4 in
|
||||||
|
// bytes 12-16 obfuscated by XOR with 0xff.
|
||||||
|
case ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00:
|
||||||
|
return []net.IP{
|
||||||
|
net.IPv4(ip16[4], ip16[5], ip16[6], ip16[7]),
|
||||||
|
net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff),
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4-compatible — deprecated ::a.b.c.d, not unwrapped by net.IP.To4
|
||||||
|
case isZeros(ip16[0:12]):
|
||||||
|
return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZeros(b []byte) bool {
|
||||||
|
for _, x := range b {
|
||||||
|
if x != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -226,9 +226,21 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
"fe80::1",
|
"fe80::1",
|
||||||
"224.0.0.1",
|
"224.0.0.1",
|
||||||
"0.0.0.0",
|
"0.0.0.0",
|
||||||
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
|
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
|
||||||
"0.255.255.255", // top of 0.0.0.0/8
|
"0.255.255.255", // top of 0.0.0.0/8
|
||||||
"255.255.255.255", // limited broadcast
|
"255.255.255.255", // limited broadcast
|
||||||
|
|
||||||
|
// IPv6 transition addresses embedding a blocked IPv4
|
||||||
|
"2002:7f00:0001::1", // 6to4 -> 127.0.0.1
|
||||||
|
"2002:a9fe:a9fe::1", // 6to4 -> 169.254.169.254
|
||||||
|
"2002:0000:0001::1", // 6to4 -> 0.0.0.1
|
||||||
|
"64:ff9b::7f00:1", // NAT64 WKP -> 127.0.0.1
|
||||||
|
"64:ff9b::a9fe:a9fe", // NAT64 WKP -> 169.254.169.254
|
||||||
|
"64:ff9b:1::7f00:1", // NAT64 local-use prefix
|
||||||
|
"2001:0000:dead:beef:0000:0000:80ff:fffe", // Teredo -> client 127.0.0.1
|
||||||
|
"2001:0000:7f00:0001::1", // Teredo -> server 127.0.0.1
|
||||||
|
"::7f00:1", // IPv4-compatible -> 127.0.0.1
|
||||||
|
"::a9fe:a9fe", // IPv4-compatible -> 169.254.169.254
|
||||||
}
|
}
|
||||||
for _, s := range blocked {
|
for _, s := range blocked {
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
@@ -242,6 +254,9 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
"172.16.5.10",
|
"172.16.5.10",
|
||||||
"8.8.8.8",
|
"8.8.8.8",
|
||||||
"2606:4700:4700::1111",
|
"2606:4700:4700::1111",
|
||||||
|
"2002:0808:0808::1", // 6to4 -> 8.8.8.8
|
||||||
|
"64:ff9b::808:808", // NAT64 WKP -> 8.8.8.8
|
||||||
|
"2001:0:808:808::1", // Teredo with public server/client
|
||||||
}
|
}
|
||||||
for _, s := range allowed {
|
for _, s := range allowed {
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/amir20/dozzle/internal/auth"
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GenerateCmd struct {
|
type GenerateCmd struct {
|
||||||
@@ -23,13 +26,24 @@ func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error {
|
|||||||
writer := zerolog.NewConsoleWriter()
|
writer := zerolog.NewConsoleWriter()
|
||||||
log.Logger = log.Output(writer)
|
log.Logger = log.Output(writer)
|
||||||
StartEvent(args, "", nil, "generate")
|
StartEvent(args, "", nil, "generate")
|
||||||
if args.Generate.Username == "" || args.Generate.Password == "" {
|
if args.Generate.Username == "" {
|
||||||
return fmt.Errorf("username and password are required")
|
return fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
password := args.Generate.Password
|
||||||
|
if password == "" {
|
||||||
|
var err error
|
||||||
|
if password, err = readPassword(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer := auth.GenerateUsers(auth.User{
|
buffer := auth.GenerateUsers(auth.User{
|
||||||
Username: args.Generate.Username,
|
Username: args.Generate.Username,
|
||||||
Password: args.Generate.Password,
|
Password: password,
|
||||||
Name: args.Generate.Name,
|
Name: args.Generate.Name,
|
||||||
Email: args.Generate.Email,
|
Email: args.Generate.Email,
|
||||||
Filter: args.Generate.Filter,
|
Filter: args.Generate.Filter,
|
||||||
@@ -42,3 +56,27 @@ func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readPassword reads a password from stdin. Prompts are written to stderr so
|
||||||
|
// they don't pollute stdout (which is commonly redirected to users.yml). When
|
||||||
|
// stdin is a terminal the input is read without echo; otherwise a single line
|
||||||
|
// is read (supports piping, e.g. `echo secret | dozzle generate ...`).
|
||||||
|
func readPassword() (string, error) {
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
if term.IsTerminal(fd) {
|
||||||
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
|
bytePassword, err := term.ReadPassword(fd)
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(string(bytePassword), "\r\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil && line == "" {
|
||||||
|
return "", fmt.Errorf("failed to read password from stdin: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(line, "\r\n"), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ func RingBufferFrom[T any](size int, data []T) *RingBuffer[T] {
|
|||||||
func (r *RingBuffer[T]) Push(data T) {
|
func (r *RingBuffer[T]) Push(data T) {
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
if r.Size <= 0 {
|
||||||
|
// A zero (or negative) capacity buffer holds nothing. Bail out before
|
||||||
|
// indexing r.data or taking a modulo by r.Size, both of which panic.
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(r.data) == r.Size {
|
if len(r.data) == r.Size {
|
||||||
r.data[r.start] = data
|
r.data[r.start] = data
|
||||||
r.start = (r.start + 1) % r.Size
|
r.start = (r.start + 1) % r.Size
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ func TestRingBuffer_MarshalJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_ZeroSize(t *testing.T) {
|
||||||
|
// A zero-capacity buffer must hold nothing rather than panic. This is
|
||||||
|
// reachable from the logs endpoint (?min=0), which builds NewRingBuffer(0).
|
||||||
|
rb := NewRingBuffer[int](0)
|
||||||
|
|
||||||
|
rb.Push(1)
|
||||||
|
rb.Push(2)
|
||||||
|
|
||||||
|
if rb.Len() != 0 {
|
||||||
|
t.Errorf("Expected len to be 0, got %d", rb.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rb.Data()
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Errorf("Expected data to be empty, got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRingBuffer_Clear(t *testing.T) {
|
func TestRingBuffer_Clear(t *testing.T) {
|
||||||
rb := NewRingBuffer[int](3)
|
rb := NewRingBuffer[int](3)
|
||||||
|
|
||||||
|
|||||||
@@ -131,3 +131,33 @@ func Test_createRoutes_simple_bad_password(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, rr.Code, 401, "Response code should be 401.")
|
assert.Equal(t, rr.Code, 401, "Response code should be 401.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_createRoutes_simple_cloud_callback_requires_auth(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.")
|
||||||
|
|
||||||
|
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/",
|
||||||
|
Authorization: Authorization{
|
||||||
|
Provider: SIMPLE,
|
||||||
|
Authorizer: auth.NewSimpleAuth(auth.UserDatabase{
|
||||||
|
Users: map[string]*auth.User{
|
||||||
|
"amir": {
|
||||||
|
Username: "amir",
|
||||||
|
Password: "$2a$10$4Tvzu0ms9shlv4B8pIfqI.TM9CoqsamsAznP91A1NGuwg/68SGS1m",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, time.Second*100),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The cloud callback must not be reachable without authentication. Otherwise
|
||||||
|
// an attacker could force-link the instance to their own cloud account.
|
||||||
|
for _, path := range []string{"/api/cloud/callback?token=attacker", "/api/cloud/status"} {
|
||||||
|
req, err := http.NewRequest("GET", path, nil)
|
||||||
|
require.NoError(t, err, "NewRequest should not return an error.")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, 401, rr.Code, "%s should require authentication.", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inverse mode excludes lines matching the regex instead of keeping them.
|
||||||
|
inverse := r.URL.Query().Get("inverse") == "true"
|
||||||
|
|
||||||
// Parse level filters if provided
|
// Parse level filters if provided
|
||||||
levels := make(map[string]struct{})
|
levels := make(map[string]struct{})
|
||||||
if r.URL.Query().Has("levels") {
|
if r.URL.Query().Has("levels") {
|
||||||
@@ -158,8 +161,9 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Filter and write events
|
// Filter and write events
|
||||||
for event := range events {
|
for event := range events {
|
||||||
// Apply regex filter if provided
|
// Apply regex filter if provided. In inverse mode a match excludes
|
||||||
if regex != nil && !support_web.Search(regex, event) {
|
// the line, so skip when the match result equals the inverse flag.
|
||||||
|
if regex != nil && inverse == support_web.Search(regex, event) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
@@ -41,3 +42,59 @@ func Test_handler_download_logs(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, rr.Code, "Status code should be 200.")
|
require.Equal(t, http.StatusOK, rr.Code, "Status code should be 200.")
|
||||||
mockedClient.AssertExpectations(t)
|
mockedClient.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_handler_download_logs_inverse_filter(t *testing.T) {
|
||||||
|
id := "123456"
|
||||||
|
data := append(
|
||||||
|
makeMessage("2020-05-13T18:55:37.772853839Z ERROR boom\n", container.STDOUT),
|
||||||
|
makeMessage("2020-05-13T18:56:37.772853839Z INFO all good\n", container.STDOUT)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
newHandler := func() http.Handler {
|
||||||
|
mockedClient := new(MockedClient)
|
||||||
|
mockedClient.On("FindContainer", mock.Anything, id).Return(container.Container{ID: id, Tty: false}, nil)
|
||||||
|
mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, mock.Anything, mock.Anything, container.STDOUT).Return(io.NopCloser(bytes.NewReader(data)), nil)
|
||||||
|
mockedClient.On("Host").Return(container.Host{ID: "localhost"})
|
||||||
|
mockedClient.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).Run(func(args mock.Arguments) {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
})
|
||||||
|
mockedClient.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
|
||||||
|
{ID: id, Name: "test", State: "running"},
|
||||||
|
}, nil)
|
||||||
|
return createDefaultHandler(mockedClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
logContents := func(t *testing.T, body []byte) string {
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, zr.File, 1, "zip should contain a single log file")
|
||||||
|
f, err := zr.File[0].Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
content, err := io.ReadAll(f)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("inverse excludes matching lines", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", "/api/containers/localhost~"+id+"/download?stdout=1&filter=ERROR&inverse=true", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
newHandler().ServeHTTP(rr, req)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
out := logContents(t, rr.Body.Bytes())
|
||||||
|
require.Contains(t, out, "all good")
|
||||||
|
require.NotContains(t, out, "boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-inverse keeps only matching lines", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", "/api/containers/localhost~"+id+"/download?stdout=1&filter=ERROR", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
newHandler().ServeHTTP(rr, req)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
out := logContents(t, rr.Body.Bytes())
|
||||||
|
require.Contains(t, out, "boom")
|
||||||
|
require.NotContains(t, out, "all good")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -216,6 +216,10 @@ func createRouter(h *handler) *chi.Mux {
|
|||||||
r.Patch("/cloud/config", h.updateCloudConfig)
|
r.Patch("/cloud/config", h.updateCloudConfig)
|
||||||
r.Delete("/cloud/config", h.deleteCloudConfig)
|
r.Delete("/cloud/config", h.deleteCloudConfig)
|
||||||
r.Post("/cloud/feedback", h.cloudFeedback)
|
r.Post("/cloud/feedback", h.cloudFeedback)
|
||||||
|
// Cloud callback handles the OAuth-style code exchange. It must stay
|
||||||
|
// authenticated so an unauthenticated attacker cannot force-link the
|
||||||
|
// instance to their own cloud account via SetCloudConfig.
|
||||||
|
r.Get("/cloud/callback", h.cloudCallback)
|
||||||
|
|
||||||
// MCP (Model Context Protocol) endpoint
|
// MCP (Model Context Protocol) endpoint
|
||||||
if h.config.EnableMCP {
|
if h.config.EnableMCP {
|
||||||
@@ -229,9 +233,6 @@ func createRouter(h *handler) *chi.Mux {
|
|||||||
r.Post("/token", h.createToken)
|
r.Post("/token", h.createToken)
|
||||||
r.Delete("/token", h.deleteToken)
|
r.Delete("/token", h.deleteToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloud callback (public, handles OAuth-style code exchange)
|
|
||||||
r.Get("/cloud/callback", h.cloudCallback)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Get("/healthcheck", h.healthcheck)
|
r.Get("/healthcheck", h.healthcheck)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Tilknyt
|
attach: Tilknyt
|
||||||
copy-permalink: Kopiér permanent link
|
copy-permalink: Kopiér permanent link
|
||||||
|
copy-image: "Kopiér image-tag"
|
||||||
copy-logs: Kopiér logs
|
copy-logs: Kopiér logs
|
||||||
copy-filtered-logs: Kopiér filtrerede logs
|
copy-filtered-logs: Kopiér filtrerede logs
|
||||||
copying-logs: Kopierer logs...
|
copying-logs: Kopierer logs...
|
||||||
@@ -39,6 +40,7 @@ label:
|
|||||||
running-containers: Kørende Containere
|
running-containers: Kørende Containere
|
||||||
all-containers: Alle Containere
|
all-containers: Alle Containere
|
||||||
all-namespaces: Alle Navneområder
|
all-namespaces: Alle Navneområder
|
||||||
|
namespaces: Navneområder
|
||||||
host: Vært
|
host: Vært
|
||||||
hosts: Værter
|
hosts: Værter
|
||||||
password: Kodeord
|
password: Kodeord
|
||||||
@@ -49,10 +51,13 @@ label:
|
|||||||
avg-cpu: Gns. CPU (%)
|
avg-cpu: Gns. CPU (%)
|
||||||
avg-mem: Gns. MEM (%)
|
avg-mem: Gns. MEM (%)
|
||||||
name: Navn
|
name: Navn
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Fastgjort
|
pinned: Fastgjort
|
||||||
per-page: Rækker per side
|
per-page: Rækker per side
|
||||||
host-menu: Værter og Containere
|
host-menu: Værter og Containere
|
||||||
swarm-menu: Tjenester og Stacks
|
swarm-menu: Tjenester og Stacks
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Brugerdefinerede Grupper
|
group-menu: Brugerdefinerede Grupper
|
||||||
no-logs: Container har ingen logs endnu
|
no-logs: Container har ingen logs endnu
|
||||||
search-status:
|
search-status:
|
||||||
@@ -112,6 +117,7 @@ title:
|
|||||||
login: Godkendelse Krævet
|
login: Godkendelse Krævet
|
||||||
dashboard: 1 container | {count} containere
|
dashboard: 1 container | {count} containere
|
||||||
settings: Indstillinger
|
settings: Indstillinger
|
||||||
|
notifications: Notifikationer
|
||||||
button:
|
button:
|
||||||
logout: Logout
|
logout: Logout
|
||||||
login: Login
|
login: Login
|
||||||
@@ -130,6 +136,7 @@ settings:
|
|||||||
options-desc: Indstillinger for sprog, navigation og gruppering.
|
options-desc: Indstillinger for sprog, navigation og gruppering.
|
||||||
cloud-desc: Stream logs til Dozzle Cloud for AI-drevne undersøgelser og søgning på tværs af installationer.
|
cloud-desc: Stream logs til Dozzle Cloud for AI-drevne undersøgelser og søgning på tværs af installationer.
|
||||||
support-title: Støt Dozzle
|
support-title: Støt Dozzle
|
||||||
|
support-help: Donationer hjælper med at holde Dozzle gratis og vedligeholdt. Tak 🙏
|
||||||
display: Visning
|
display: Visning
|
||||||
locale: Overskriv sprog
|
locale: Overskriv sprog
|
||||||
small-scrollbars: Brug mindre scrollbarer
|
small-scrollbars: Brug mindre scrollbarer
|
||||||
@@ -206,6 +213,12 @@ analytics:
|
|||||||
example_all: Alle logfiler
|
example_all: Alle logfiler
|
||||||
example_count: Antal rækker
|
example_count: Antal rækker
|
||||||
example_group: Antal efter {column}
|
example_group: Antal efter {column}
|
||||||
|
export: Eksporter
|
||||||
|
export_csv: Download CSV
|
||||||
|
export_json: Download JSON
|
||||||
|
drawer:
|
||||||
|
maximize: Maksimer
|
||||||
|
restore: Gendan
|
||||||
notifications:
|
notifications:
|
||||||
title: Notifikationer
|
title: Notifikationer
|
||||||
description: Konfigurer hvor og hvornår du vil modtage alarmer
|
description: Konfigurer hvor og hvornår du vil modtage alarmer
|
||||||
@@ -213,6 +226,8 @@ notifications:
|
|||||||
add-destination: Tilføj destination
|
add-destination: Tilføj destination
|
||||||
alerts: Alarmer
|
alerts: Alarmer
|
||||||
add-alert: Tilføj alarm
|
add-alert: Tilføj alarm
|
||||||
|
prefill-name: Fejlalarmer
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Tilføj
|
add: Tilføj
|
||||||
filter:
|
filter:
|
||||||
all: Alle ({count})
|
all: Alle ({count})
|
||||||
@@ -318,6 +333,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Kom i gang med notifikationer
|
title: Kom i gang med notifikationer
|
||||||
description: Vælg hvordan du vil modtage alarmer, når dine containere kræver opmærksomhed.
|
description: Vælg hvordan du vil modtage alarmer, når dine containere kræver opmærksomhed.
|
||||||
|
cloud-subtitle: AI-resuméer, fjernstyring og mere
|
||||||
|
webhook-subtitle: Send alarmer til ethvert endpoint
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud Linket
|
title: Dozzle Cloud Linket
|
||||||
message: Din instans er blevet tilsluttet. Du kan fjerne tilknytningen i indstillinger eller tjekke dit forbrug når som helst.
|
message: Din instans er blevet tilsluttet. Du kan fjerne tilknytningen i indstillinger eller tjekke dit forbrug når som helst.
|
||||||
@@ -397,3 +414,19 @@ cloud-search:
|
|||||||
container-removed: "Containeren er slettet"
|
container-removed: "Containeren er slettet"
|
||||||
cta-settings: "Cloud-indstillinger"
|
cta-settings: "Cloud-indstillinger"
|
||||||
container-removed-pill: "slettet"
|
container-removed-pill: "slettet"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Kommandoer
|
||||||
|
restart-container: Genstart {name}
|
||||||
|
stop-container: Stop {name}
|
||||||
|
start-container: Start {name}
|
||||||
|
update-container: Opdater {name}
|
||||||
|
theme-auto: Brug systemtema
|
||||||
|
theme-light: Skift til lyst tema
|
||||||
|
theme-dark: Skift til mørkt tema
|
||||||
|
toggle-compact: Skift kompakt tilstand
|
||||||
|
toggle-timestamps: Skift tidsstempler
|
||||||
|
toggle-soft-wrap: Skift blød ombrydning
|
||||||
|
toggle-stopped: Skift stoppede containere
|
||||||
|
toggle-std: Skift stdout/stderr-etiketter
|
||||||
|
toggle-scrollbars: Skift mindre rullepaneler
|
||||||
|
open-settings: Åbn indstillinger
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Anhängen
|
attach: Anhängen
|
||||||
copy-permalink: Permanentlink kopieren
|
copy-permalink: Permanentlink kopieren
|
||||||
|
copy-image: "Image-Tag kopieren"
|
||||||
copy-logs: Logs kopieren
|
copy-logs: Logs kopieren
|
||||||
copy-filtered-logs: Gefilterte Logs kopieren
|
copy-filtered-logs: Gefilterte Logs kopieren
|
||||||
copying-logs: Logs werden kopiert...
|
copying-logs: Logs werden kopiert...
|
||||||
@@ -39,6 +40,7 @@ label:
|
|||||||
running-containers: Laufende Container
|
running-containers: Laufende Container
|
||||||
all-containers: Alle Container
|
all-containers: Alle Container
|
||||||
all-namespaces: Alle Namespaces
|
all-namespaces: Alle Namespaces
|
||||||
|
namespaces: Namespaces
|
||||||
host: Host
|
host: Host
|
||||||
hosts: Hosts
|
hosts: Hosts
|
||||||
password: Passwort
|
password: Passwort
|
||||||
@@ -49,10 +51,13 @@ label:
|
|||||||
avg-cpu: Avg. CPU (%)
|
avg-cpu: Avg. CPU (%)
|
||||||
avg-mem: Avg. MEM (%)
|
avg-mem: Avg. MEM (%)
|
||||||
name: Name
|
name: Name
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Angepinnt
|
pinned: Angepinnt
|
||||||
per-page: Zeilen pro Seite
|
per-page: Zeilen pro Seite
|
||||||
host-menu: Hosts und Container
|
host-menu: Hosts und Container
|
||||||
swarm-menu: Services und Stacks
|
swarm-menu: Services und Stacks
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Benutzerdefinierte Gruppen
|
group-menu: Benutzerdefinierte Gruppen
|
||||||
no-logs: Container hat noch keine Logs
|
no-logs: Container hat noch keine Logs
|
||||||
search-status:
|
search-status:
|
||||||
@@ -112,6 +117,7 @@ title:
|
|||||||
login: Authentifizierung erforderlich
|
login: Authentifizierung erforderlich
|
||||||
dashboard: 1 Container | {count} Container
|
dashboard: 1 Container | {count} Container
|
||||||
settings: Einstellungen
|
settings: Einstellungen
|
||||||
|
notifications: Benachrichtigungen
|
||||||
button:
|
button:
|
||||||
logout: Ausloggen
|
logout: Ausloggen
|
||||||
login: Anmeldung
|
login: Anmeldung
|
||||||
@@ -130,6 +136,7 @@ settings:
|
|||||||
options-desc: Einstellungen für Sprache, Navigation und Gruppierung.
|
options-desc: Einstellungen für Sprache, Navigation und Gruppierung.
|
||||||
cloud-desc: Streamen Sie Logs an Dozzle Cloud für KI-gestützte Analysen und Suche über Instanzen hinweg.
|
cloud-desc: Streamen Sie Logs an Dozzle Cloud für KI-gestützte Analysen und Suche über Instanzen hinweg.
|
||||||
support-title: Dozzle unterstützen
|
support-title: Dozzle unterstützen
|
||||||
|
support-help: Spenden helfen, Dozzle kostenlos und gepflegt zu halten. Vielen Dank 🙏
|
||||||
display: Anzeige
|
display: Anzeige
|
||||||
locale: Sprache überschreiben
|
locale: Sprache überschreiben
|
||||||
small-scrollbars: Verwende kleinere Scrollbars
|
small-scrollbars: Verwende kleinere Scrollbars
|
||||||
@@ -206,6 +213,12 @@ analytics:
|
|||||||
example_all: Alle Logs
|
example_all: Alle Logs
|
||||||
example_count: Anzahl Zeilen
|
example_count: Anzahl Zeilen
|
||||||
example_group: Anzahl nach {column}
|
example_group: Anzahl nach {column}
|
||||||
|
export: Exportieren
|
||||||
|
export_csv: CSV herunterladen
|
||||||
|
export_json: JSON herunterladen
|
||||||
|
drawer:
|
||||||
|
maximize: Maximieren
|
||||||
|
restore: Wiederherstellen
|
||||||
notifications:
|
notifications:
|
||||||
title: Benachrichtigungen
|
title: Benachrichtigungen
|
||||||
description: Konfigurieren Sie, wo und wann Sie Alarme erhalten möchten
|
description: Konfigurieren Sie, wo und wann Sie Alarme erhalten möchten
|
||||||
@@ -213,6 +226,8 @@ notifications:
|
|||||||
add-destination: Ziel hinzufügen
|
add-destination: Ziel hinzufügen
|
||||||
alerts: Alarme
|
alerts: Alarme
|
||||||
add-alert: Alarm hinzufügen
|
add-alert: Alarm hinzufügen
|
||||||
|
prefill-name: Fehler-Alarme
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Hinzufügen
|
add: Hinzufügen
|
||||||
filter:
|
filter:
|
||||||
all: Alle ({count})
|
all: Alle ({count})
|
||||||
@@ -318,6 +333,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Erste Schritte mit Benachrichtigungen
|
title: Erste Schritte mit Benachrichtigungen
|
||||||
description: Wählen Sie aus, wie Sie Alarme erhalten möchten, wenn Ihre Container Aufmerksamkeit benötigen.
|
description: Wählen Sie aus, wie Sie Alarme erhalten möchten, wenn Ihre Container Aufmerksamkeit benötigen.
|
||||||
|
cloud-subtitle: KI-Zusammenfassungen, Fernsteuerung und mehr
|
||||||
|
webhook-subtitle: Alarme an jeden Endpunkt senden
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud verknüpft
|
title: Dozzle Cloud verknüpft
|
||||||
message: Ihre Instanz wurde erfolgreich verbunden. Sie können die Verknüpfung in den Einstellungen aufheben oder Ihre Nutzung jederzeit einsehen.
|
message: Ihre Instanz wurde erfolgreich verbunden. Sie können die Verknüpfung in den Einstellungen aufheben oder Ihre Nutzung jederzeit einsehen.
|
||||||
@@ -397,3 +414,19 @@ cloud-search:
|
|||||||
container-removed: "Container wurde gelöscht"
|
container-removed: "Container wurde gelöscht"
|
||||||
cta-settings: "Cloud-Einstellungen"
|
cta-settings: "Cloud-Einstellungen"
|
||||||
container-removed-pill: "gelöscht"
|
container-removed-pill: "gelöscht"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Befehle
|
||||||
|
restart-container: "{name} neu starten"
|
||||||
|
stop-container: "{name} stoppen"
|
||||||
|
start-container: "{name} starten"
|
||||||
|
update-container: "{name} aktualisieren"
|
||||||
|
theme-auto: Systemdesign verwenden
|
||||||
|
theme-light: Zu hellem Design wechseln
|
||||||
|
theme-dark: Zu dunklem Design wechseln
|
||||||
|
toggle-compact: Kompaktmodus umschalten
|
||||||
|
toggle-timestamps: Zeitstempel umschalten
|
||||||
|
toggle-soft-wrap: Zeilenumbruch umschalten
|
||||||
|
toggle-stopped: Gestoppte Container umschalten
|
||||||
|
toggle-std: stdout/stderr-Labels umschalten
|
||||||
|
toggle-scrollbars: Kleinere Scrollleisten umschalten
|
||||||
|
open-settings: Einstellungen öffnen
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Attach
|
attach: Attach
|
||||||
copy-permalink: Copy permanent link
|
copy-permalink: Copy permanent link
|
||||||
|
copy-image: Copy image tag
|
||||||
copy-logs: Copy logs
|
copy-logs: Copy logs
|
||||||
copy-filtered-logs: Copy Filtered Logs
|
copy-filtered-logs: Copy Filtered Logs
|
||||||
copying-logs: Copying logs...
|
copying-logs: Copying logs...
|
||||||
@@ -159,6 +160,22 @@ cloud-search:
|
|||||||
container-removed: Container has been deleted
|
container-removed: Container has been deleted
|
||||||
cta-settings: "Cloud settings"
|
cta-settings: "Cloud settings"
|
||||||
container-removed-pill: removed
|
container-removed-pill: removed
|
||||||
|
command-palette:
|
||||||
|
section-commands: Commands
|
||||||
|
restart-container: Restart {name}
|
||||||
|
stop-container: Stop {name}
|
||||||
|
start-container: Start {name}
|
||||||
|
update-container: Update {name}
|
||||||
|
theme-auto: Use system theme
|
||||||
|
theme-light: Switch to light theme
|
||||||
|
theme-dark: Switch to dark theme
|
||||||
|
toggle-compact: Toggle compact mode
|
||||||
|
toggle-timestamps: Toggle timestamps
|
||||||
|
toggle-soft-wrap: Toggle soft wrap
|
||||||
|
toggle-stopped: Toggle stopped containers
|
||||||
|
toggle-std: Toggle stdout/stderr labels
|
||||||
|
toggle-scrollbars: Toggle smaller scrollbars
|
||||||
|
open-settings: Open settings
|
||||||
settings:
|
settings:
|
||||||
help-support: >
|
help-support: >
|
||||||
Please support Dozzle by donating or sponsoring us on GitHub. Your contributions help us improve Dozzle for
|
Please support Dozzle by donating or sponsoring us on GitHub. Your contributions help us improve Dozzle for
|
||||||
@@ -246,6 +263,12 @@ analytics:
|
|||||||
example_all: All logs
|
example_all: All logs
|
||||||
example_count: Row count
|
example_count: Row count
|
||||||
example_group: Count by {column}
|
example_group: Count by {column}
|
||||||
|
export: Export
|
||||||
|
export_csv: Download CSV
|
||||||
|
export_json: Download JSON
|
||||||
|
drawer:
|
||||||
|
maximize: Maximize
|
||||||
|
restore: Restore
|
||||||
notifications:
|
notifications:
|
||||||
title: Notifications
|
title: Notifications
|
||||||
description: Configure where and when to receive alerts
|
description: Configure where and when to receive alerts
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Conectar
|
attach: Conectar
|
||||||
copy-permalink: Copiar enlace permanente
|
copy-permalink: Copiar enlace permanente
|
||||||
|
copy-image: "Copiar etiqueta de imagen"
|
||||||
copy-logs: Copiar registros
|
copy-logs: Copiar registros
|
||||||
copy-filtered-logs: Copiar registros filtrados
|
copy-filtered-logs: Copiar registros filtrados
|
||||||
copying-logs: Copiando registros...
|
copying-logs: Copiando registros...
|
||||||
@@ -39,6 +40,7 @@ label:
|
|||||||
running-containers: Contenedores en ejecución
|
running-containers: Contenedores en ejecución
|
||||||
all-containers: Todos los contenedores
|
all-containers: Todos los contenedores
|
||||||
all-namespaces: Todos los espacios de nombres
|
all-namespaces: Todos los espacios de nombres
|
||||||
|
namespaces: Espacios de nombres
|
||||||
host: Host
|
host: Host
|
||||||
hosts: Hosts
|
hosts: Hosts
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
@@ -49,10 +51,13 @@ label:
|
|||||||
avg-cpu: Promedio de CPU (%)
|
avg-cpu: Promedio de CPU (%)
|
||||||
avg-mem: Promedio de MEM (%)
|
avg-mem: Promedio de MEM (%)
|
||||||
name: Nombre
|
name: Nombre
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Fijado
|
pinned: Fijado
|
||||||
per-page: Filas por página
|
per-page: Filas por página
|
||||||
host-menu: Hosts y Contenedores
|
host-menu: Hosts y Contenedores
|
||||||
swarm-menu: Servicios y Stacks
|
swarm-menu: Servicios y Stacks
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Grupos Personalizados
|
group-menu: Grupos Personalizados
|
||||||
no-logs: El contenedor aún no tiene registros
|
no-logs: El contenedor aún no tiene registros
|
||||||
search-status:
|
search-status:
|
||||||
@@ -112,6 +117,7 @@ title:
|
|||||||
login: Autenticación requerida
|
login: Autenticación requerida
|
||||||
dashboard: 1 contenedor | {count} contenedores
|
dashboard: 1 contenedor | {count} contenedores
|
||||||
settings: Configuración
|
settings: Configuración
|
||||||
|
notifications: Notificaciones
|
||||||
button:
|
button:
|
||||||
logout: Cerrar la sesión
|
logout: Cerrar la sesión
|
||||||
login: Iniciar sesión
|
login: Iniciar sesión
|
||||||
@@ -150,6 +156,22 @@ cloud-search:
|
|||||||
container-removed: "El contenedor ha sido eliminado"
|
container-removed: "El contenedor ha sido eliminado"
|
||||||
cta-settings: "Configuración de Cloud"
|
cta-settings: "Configuración de Cloud"
|
||||||
container-removed-pill: "eliminado"
|
container-removed-pill: "eliminado"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Comandos
|
||||||
|
restart-container: Reiniciar {name}
|
||||||
|
stop-container: Detener {name}
|
||||||
|
start-container: Iniciar {name}
|
||||||
|
update-container: Actualizar {name}
|
||||||
|
theme-auto: Usar tema del sistema
|
||||||
|
theme-light: Cambiar a tema claro
|
||||||
|
theme-dark: Cambiar a tema oscuro
|
||||||
|
toggle-compact: Alternar modo compacto
|
||||||
|
toggle-timestamps: Alternar marcas de tiempo
|
||||||
|
toggle-soft-wrap: Alternar ajuste de línea
|
||||||
|
toggle-stopped: Alternar contenedores detenidos
|
||||||
|
toggle-std: Alternar etiquetas stdout/stderr
|
||||||
|
toggle-scrollbars: Alternar barras de desplazamiento más pequeñas
|
||||||
|
open-settings: Abrir configuración
|
||||||
settings:
|
settings:
|
||||||
help-support: >
|
help-support: >
|
||||||
Por favor, apoya a Dozzle donando o patrocinándonos en GitHub. Tus contribuciones nos ayudan a mejorar Dozzle para todos. ¡Gracias! 🙏🏼
|
Por favor, apoya a Dozzle donando o patrocinándonos en GitHub. Tus contribuciones nos ayudan a mejorar Dozzle para todos. ¡Gracias! 🙏🏼
|
||||||
@@ -158,6 +180,7 @@ settings:
|
|||||||
options-desc: Preferencias de idioma, navegación y agrupación.
|
options-desc: Preferencias de idioma, navegación y agrupación.
|
||||||
cloud-desc: Transmite logs a Dozzle Cloud para investigaciones con IA y búsqueda entre instancias.
|
cloud-desc: Transmite logs a Dozzle Cloud para investigaciones con IA y búsqueda entre instancias.
|
||||||
support-title: Apoya a Dozzle
|
support-title: Apoya a Dozzle
|
||||||
|
support-help: Las donaciones ayudan a mantener Dozzle gratuito y actualizado. Gracias 🙏
|
||||||
display: Vista
|
display: Vista
|
||||||
locale: Sobrescribir idioma
|
locale: Sobrescribir idioma
|
||||||
small-scrollbars: Utilizar barras de desplazamiento más pequeñas
|
small-scrollbars: Utilizar barras de desplazamiento más pequeñas
|
||||||
@@ -234,6 +257,12 @@ analytics:
|
|||||||
example_all: Todos los registros
|
example_all: Todos los registros
|
||||||
example_count: Número de filas
|
example_count: Número de filas
|
||||||
example_group: Conteo por {column}
|
example_group: Conteo por {column}
|
||||||
|
export: Exportar
|
||||||
|
export_csv: Descargar CSV
|
||||||
|
export_json: Descargar JSON
|
||||||
|
drawer:
|
||||||
|
maximize: Maximizar
|
||||||
|
restore: Restaurar
|
||||||
notifications:
|
notifications:
|
||||||
title: Notificaciones
|
title: Notificaciones
|
||||||
description: Configure dónde y cuándo recibir alertas
|
description: Configure dónde y cuándo recibir alertas
|
||||||
@@ -241,6 +270,8 @@ notifications:
|
|||||||
add-destination: Añadir destino
|
add-destination: Añadir destino
|
||||||
alerts: Alertas
|
alerts: Alertas
|
||||||
add-alert: Añadir alerta
|
add-alert: Añadir alerta
|
||||||
|
prefill-name: Alertas de error
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Añadir
|
add: Añadir
|
||||||
filter:
|
filter:
|
||||||
all: Todos ({count})
|
all: Todos ({count})
|
||||||
@@ -346,6 +377,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Comenzar con las notificaciones
|
title: Comenzar con las notificaciones
|
||||||
description: Elija cómo desea recibir alertas cuando sus contenedores necesiten atención.
|
description: Elija cómo desea recibir alertas cuando sus contenedores necesiten atención.
|
||||||
|
cloud-subtitle: Resúmenes con IA, control remoto y más
|
||||||
|
webhook-subtitle: Envía alertas a cualquier endpoint
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud Vinculado
|
title: Dozzle Cloud Vinculado
|
||||||
message: Su instancia se ha conectado correctamente. Puede desvincularla en la configuración o consultar su uso en cualquier momento.
|
message: Su instancia se ha conectado correctamente. Puede desvincularla en la configuración o consultar su uso en cualquier momento.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Attacher
|
attach: Attacher
|
||||||
copy-permalink: Copier le lien permanent
|
copy-permalink: Copier le lien permanent
|
||||||
|
copy-image: "Copier le tag de l'image"
|
||||||
copy-logs: Copier les journaux
|
copy-logs: Copier les journaux
|
||||||
copy-filtered-logs: Copier les journaux filtrés
|
copy-filtered-logs: Copier les journaux filtrés
|
||||||
copying-logs: Copie des journaux...
|
copying-logs: Copie des journaux...
|
||||||
@@ -39,6 +40,7 @@ label:
|
|||||||
running-containers: Conteneurs en execution
|
running-containers: Conteneurs en execution
|
||||||
all-containers: Tous les conteneurs
|
all-containers: Tous les conteneurs
|
||||||
all-namespaces: Tous les espaces de noms
|
all-namespaces: Tous les espaces de noms
|
||||||
|
namespaces: Espaces de noms
|
||||||
host: Hôte
|
host: Hôte
|
||||||
hosts: Hôtes
|
hosts: Hôtes
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
@@ -49,10 +51,13 @@ label:
|
|||||||
avg-cpu: Moyenne CPU (%)
|
avg-cpu: Moyenne CPU (%)
|
||||||
avg-mem: Moyenne MEM (%)
|
avg-mem: Moyenne MEM (%)
|
||||||
name: Nom
|
name: Nom
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Epinglé
|
pinned: Epinglé
|
||||||
per-page: Colonnes par page
|
per-page: Colonnes par page
|
||||||
host-menu: Hôtes et Conteneurs
|
host-menu: Hôtes et Conteneurs
|
||||||
swarm-menu: Services et Stacks
|
swarm-menu: Services et Stacks
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Groupes Personnalisés
|
group-menu: Groupes Personnalisés
|
||||||
no-logs: Le conteneur n'a pas encore de logs
|
no-logs: Le conteneur n'a pas encore de logs
|
||||||
search-status:
|
search-status:
|
||||||
@@ -112,6 +117,7 @@ title:
|
|||||||
login: Authentification requise
|
login: Authentification requise
|
||||||
dashboard: 1 conteneur | {count} conteneurs
|
dashboard: 1 conteneur | {count} conteneurs
|
||||||
settings: Paramètres
|
settings: Paramètres
|
||||||
|
notifications: Notifications
|
||||||
button:
|
button:
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
login: Connexion
|
login: Connexion
|
||||||
@@ -130,6 +136,7 @@ settings:
|
|||||||
options-desc: Préférences de langue, de navigation et de regroupement.
|
options-desc: Préférences de langue, de navigation et de regroupement.
|
||||||
cloud-desc: Diffusez les logs vers Dozzle Cloud pour des analyses assistées par IA et la recherche entre instances.
|
cloud-desc: Diffusez les logs vers Dozzle Cloud pour des analyses assistées par IA et la recherche entre instances.
|
||||||
support-title: Soutenir Dozzle
|
support-title: Soutenir Dozzle
|
||||||
|
support-help: Les dons aident à garder Dozzle gratuit et maintenu. Merci 🙏
|
||||||
display: Afficher
|
display: Afficher
|
||||||
locale: Langue de remplacement
|
locale: Langue de remplacement
|
||||||
small-scrollbars: Utiliser des barres de défilement plus petites
|
small-scrollbars: Utiliser des barres de défilement plus petites
|
||||||
@@ -206,6 +213,12 @@ analytics:
|
|||||||
example_all: Tous les journaux
|
example_all: Tous les journaux
|
||||||
example_count: Nombre de lignes
|
example_count: Nombre de lignes
|
||||||
example_group: Nombre par {column}
|
example_group: Nombre par {column}
|
||||||
|
export: Exporter
|
||||||
|
export_csv: Télécharger le CSV
|
||||||
|
export_json: Télécharger le JSON
|
||||||
|
drawer:
|
||||||
|
maximize: Agrandir
|
||||||
|
restore: Restaurer
|
||||||
notifications:
|
notifications:
|
||||||
title: Notifications
|
title: Notifications
|
||||||
description: Configurez où et quand recevoir des alertes
|
description: Configurez où et quand recevoir des alertes
|
||||||
@@ -213,6 +226,8 @@ notifications:
|
|||||||
add-destination: Ajouter une destination
|
add-destination: Ajouter une destination
|
||||||
alerts: Alertes
|
alerts: Alertes
|
||||||
add-alert: Ajouter une alerte
|
add-alert: Ajouter une alerte
|
||||||
|
prefill-name: Alertes d'erreur
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Ajouter
|
add: Ajouter
|
||||||
filter:
|
filter:
|
||||||
all: Tous ({count})
|
all: Tous ({count})
|
||||||
@@ -318,6 +333,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Commencer avec les notifications
|
title: Commencer avec les notifications
|
||||||
description: Choisissez comment vous souhaitez recevoir des alertes lorsque vos conteneurs nécessitent une attention.
|
description: Choisissez comment vous souhaitez recevoir des alertes lorsque vos conteneurs nécessitent une attention.
|
||||||
|
cloud-subtitle: Résumés IA, contrôle à distance et plus
|
||||||
|
webhook-subtitle: Envoyez des alertes vers n'importe quel endpoint
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud Lié
|
title: Dozzle Cloud Lié
|
||||||
message: Votre instance a été connectée avec succès. Vous pouvez la délier dans les paramètres ou consulter votre utilisation à tout moment.
|
message: Votre instance a été connectée avec succès. Vous pouvez la délier dans les paramètres ou consulter votre utilisation à tout moment.
|
||||||
@@ -397,3 +414,19 @@ cloud-search:
|
|||||||
container-removed: "Le conteneur a été supprimé"
|
container-removed: "Le conteneur a été supprimé"
|
||||||
cta-settings: "Paramètres Cloud"
|
cta-settings: "Paramètres Cloud"
|
||||||
container-removed-pill: "supprimé"
|
container-removed-pill: "supprimé"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Commandes
|
||||||
|
restart-container: Redémarrer {name}
|
||||||
|
stop-container: Arrêter {name}
|
||||||
|
start-container: Démarrer {name}
|
||||||
|
update-container: Mettre à jour {name}
|
||||||
|
theme-auto: Utiliser le thème du système
|
||||||
|
theme-light: Passer au thème clair
|
||||||
|
theme-dark: Passer au thème sombre
|
||||||
|
toggle-compact: Basculer le mode compact
|
||||||
|
toggle-timestamps: Basculer les horodatages
|
||||||
|
toggle-soft-wrap: Basculer le retour à la ligne
|
||||||
|
toggle-stopped: Basculer les conteneurs arrêtés
|
||||||
|
toggle-std: Basculer les étiquettes stdout/stderr
|
||||||
|
toggle-scrollbars: Basculer les barres de défilement réduites
|
||||||
|
open-settings: Ouvrir les paramètres
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Lampirkan
|
attach: Lampirkan
|
||||||
copy-permalink: Salin tautan permanen
|
copy-permalink: Salin tautan permanen
|
||||||
|
copy-image: "Salin tag image"
|
||||||
copy-logs: Salin log
|
copy-logs: Salin log
|
||||||
copy-filtered-logs: Salin log terfilter
|
copy-filtered-logs: Salin log terfilter
|
||||||
copying-logs: Menyalin log...
|
copying-logs: Menyalin log...
|
||||||
@@ -40,6 +41,7 @@ label:
|
|||||||
running-containers: Kontainer Berjalan
|
running-containers: Kontainer Berjalan
|
||||||
all-containers: Semua Kontainer
|
all-containers: Semua Kontainer
|
||||||
all-namespaces: Semua Namespace
|
all-namespaces: Semua Namespace
|
||||||
|
namespaces: Namespace
|
||||||
host: Host
|
host: Host
|
||||||
hosts: Host
|
hosts: Host
|
||||||
password: Kata sandi
|
password: Kata sandi
|
||||||
@@ -50,10 +52,13 @@ label:
|
|||||||
avg-cpu: Rata-rata CPU (%)
|
avg-cpu: Rata-rata CPU (%)
|
||||||
avg-mem: Rata-rata MEM (%)
|
avg-mem: Rata-rata MEM (%)
|
||||||
name: Nama
|
name: Nama
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Disematkan
|
pinned: Disematkan
|
||||||
per-page: Baris per halaman
|
per-page: Baris per halaman
|
||||||
host-menu: Host dan Kontainer
|
host-menu: Host dan Kontainer
|
||||||
swarm-menu: Layanan dan Stack
|
swarm-menu: Layanan dan Stack
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Grup Kustom
|
group-menu: Grup Kustom
|
||||||
no-logs: Kontainer belum memiliki log
|
no-logs: Kontainer belum memiliki log
|
||||||
search-status:
|
search-status:
|
||||||
@@ -117,6 +122,7 @@ title:
|
|||||||
login: Autentikasi Diperlukan
|
login: Autentikasi Diperlukan
|
||||||
dashboard: 1 kontainer | {count} kontainer
|
dashboard: 1 kontainer | {count} kontainer
|
||||||
settings: Pengaturan
|
settings: Pengaturan
|
||||||
|
notifications: Notifikasi
|
||||||
|
|
||||||
button:
|
button:
|
||||||
logout: Keluar
|
logout: Keluar
|
||||||
@@ -138,6 +144,7 @@ settings:
|
|||||||
options-desc: Preferensi bahasa, navigasi, dan pengelompokan.
|
options-desc: Preferensi bahasa, navigasi, dan pengelompokan.
|
||||||
cloud-desc: Streaming log ke Dozzle Cloud untuk investigasi bertenaga AI dan pencarian lintas instans.
|
cloud-desc: Streaming log ke Dozzle Cloud untuk investigasi bertenaga AI dan pencarian lintas instans.
|
||||||
support-title: Dukung Dozzle
|
support-title: Dukung Dozzle
|
||||||
|
support-help: Donasi membantu menjaga Dozzle tetap gratis dan terawat. Terima kasih 🙏
|
||||||
display: Tampilan
|
display: Tampilan
|
||||||
locale: Ganti bahasa
|
locale: Ganti bahasa
|
||||||
small-scrollbars: Gunakan scrollbar kecil
|
small-scrollbars: Gunakan scrollbar kecil
|
||||||
@@ -218,6 +225,12 @@ analytics:
|
|||||||
example_all: Semua log
|
example_all: Semua log
|
||||||
example_count: Jumlah baris
|
example_count: Jumlah baris
|
||||||
example_group: Hitung berdasarkan {column}
|
example_group: Hitung berdasarkan {column}
|
||||||
|
export: Ekspor
|
||||||
|
export_csv: Unduh CSV
|
||||||
|
export_json: Unduh JSON
|
||||||
|
drawer:
|
||||||
|
maximize: Maksimalkan
|
||||||
|
restore: Pulihkan
|
||||||
notifications:
|
notifications:
|
||||||
title: Notifikasi
|
title: Notifikasi
|
||||||
description: Konfigurasikan di mana dan kapan menerima peringatan
|
description: Konfigurasikan di mana dan kapan menerima peringatan
|
||||||
@@ -225,6 +238,8 @@ notifications:
|
|||||||
add-destination: Tambah tujuan
|
add-destination: Tambah tujuan
|
||||||
alerts: Peringatan
|
alerts: Peringatan
|
||||||
add-alert: Tambah peringatan
|
add-alert: Tambah peringatan
|
||||||
|
prefill-name: Peringatan kesalahan
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Tambah
|
add: Tambah
|
||||||
filter:
|
filter:
|
||||||
all: Semua ({count})
|
all: Semua ({count})
|
||||||
@@ -330,6 +345,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Mulai dengan notifikasi
|
title: Mulai dengan notifikasi
|
||||||
description: Pilih cara Anda ingin menerima peringatan saat kontainer Anda membutuhkan perhatian.
|
description: Pilih cara Anda ingin menerima peringatan saat kontainer Anda membutuhkan perhatian.
|
||||||
|
cloud-subtitle: Ringkasan AI, kendali jarak jauh & lainnya
|
||||||
|
webhook-subtitle: Kirim peringatan ke endpoint mana pun
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud Tertaut
|
title: Dozzle Cloud Tertaut
|
||||||
message: Instans Anda telah berhasil terhubung. Anda dapat melepaskan tautan di pengaturan atau memeriksa penggunaan Anda kapan saja.
|
message: Instans Anda telah berhasil terhubung. Anda dapat melepaskan tautan di pengaturan atau memeriksa penggunaan Anda kapan saja.
|
||||||
@@ -409,3 +426,19 @@ cloud-search:
|
|||||||
container-removed: "Kontainer telah dihapus"
|
container-removed: "Kontainer telah dihapus"
|
||||||
cta-settings: "Pengaturan Cloud"
|
cta-settings: "Pengaturan Cloud"
|
||||||
container-removed-pill: "dihapus"
|
container-removed-pill: "dihapus"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Perintah
|
||||||
|
restart-container: Mulai ulang {name}
|
||||||
|
stop-container: Hentikan {name}
|
||||||
|
start-container: Jalankan {name}
|
||||||
|
update-container: Perbarui {name}
|
||||||
|
theme-auto: Gunakan tema sistem
|
||||||
|
theme-light: Beralih ke tema terang
|
||||||
|
theme-dark: Beralih ke tema gelap
|
||||||
|
toggle-compact: Alihkan mode ringkas
|
||||||
|
toggle-timestamps: Alihkan stempel waktu
|
||||||
|
toggle-soft-wrap: Alihkan bungkus lembut
|
||||||
|
toggle-stopped: Alihkan kontainer yang dihentikan
|
||||||
|
toggle-std: Alihkan label stdout/stderr
|
||||||
|
toggle-scrollbars: Alihkan bilah gulir lebih kecil
|
||||||
|
open-settings: Buka pengaturan
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Collega
|
attach: Collega
|
||||||
copy-permalink: Copia link permanente
|
copy-permalink: Copia link permanente
|
||||||
|
copy-image: "Copia tag immagine"
|
||||||
copy-logs: Copia log
|
copy-logs: Copia log
|
||||||
copy-filtered-logs: Copia log filtrati
|
copy-filtered-logs: Copia log filtrati
|
||||||
copying-logs: Copia dei log...
|
copying-logs: Copia dei log...
|
||||||
@@ -39,6 +40,7 @@ label:
|
|||||||
running-containers: Container in esecuzione
|
running-containers: Container in esecuzione
|
||||||
all-containers: Tutti i Container
|
all-containers: Tutti i Container
|
||||||
all-namespaces: Tutti i Namespace
|
all-namespaces: Tutti i Namespace
|
||||||
|
namespaces: Namespace
|
||||||
host: Host
|
host: Host
|
||||||
hosts: Hosts
|
hosts: Hosts
|
||||||
password: Password
|
password: Password
|
||||||
@@ -49,10 +51,13 @@ label:
|
|||||||
avg-cpu: CPU (%)
|
avg-cpu: CPU (%)
|
||||||
avg-mem: MEM (%)
|
avg-mem: MEM (%)
|
||||||
name: Nome
|
name: Nome
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Fissato
|
pinned: Fissato
|
||||||
per-page: Righe per pagina
|
per-page: Righe per pagina
|
||||||
host-menu: Host e Container
|
host-menu: Host e Container
|
||||||
swarm-menu: Servizi e Stack
|
swarm-menu: Servizi e Stack
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Gruppi Personalizzati
|
group-menu: Gruppi Personalizzati
|
||||||
no-logs: Il container non ha ancora log
|
no-logs: Il container non ha ancora log
|
||||||
search-status:
|
search-status:
|
||||||
@@ -112,6 +117,7 @@ title:
|
|||||||
login: Autenticazione richiesta
|
login: Autenticazione richiesta
|
||||||
dashboard: 1 container | {count} container
|
dashboard: 1 container | {count} container
|
||||||
settings: Impostazioni
|
settings: Impostazioni
|
||||||
|
notifications: Notifiche
|
||||||
button:
|
button:
|
||||||
logout: Logout
|
logout: Logout
|
||||||
login: Login
|
login: Login
|
||||||
@@ -130,6 +136,7 @@ settings:
|
|||||||
options-desc: Preferenze di lingua, navigazione e raggruppamento.
|
options-desc: Preferenze di lingua, navigazione e raggruppamento.
|
||||||
cloud-desc: Trasmetti i log a Dozzle Cloud per analisi basate su IA e ricerca tra le istanze.
|
cloud-desc: Trasmetti i log a Dozzle Cloud per analisi basate su IA e ricerca tra le istanze.
|
||||||
support-title: Sostieni Dozzle
|
support-title: Sostieni Dozzle
|
||||||
|
support-help: Le donazioni aiutano a mantenere Dozzle gratuito e curato. Grazie 🙏
|
||||||
display: Visualizza
|
display: Visualizza
|
||||||
locale: Sovrascrivi Lingua
|
locale: Sovrascrivi Lingua
|
||||||
small-scrollbars: Usa una scrollbars più piccola
|
small-scrollbars: Usa una scrollbars più piccola
|
||||||
@@ -206,6 +213,12 @@ analytics:
|
|||||||
example_all: Tutti i log
|
example_all: Tutti i log
|
||||||
example_count: Numero di righe
|
example_count: Numero di righe
|
||||||
example_group: Conteggio per {column}
|
example_group: Conteggio per {column}
|
||||||
|
export: Esporta
|
||||||
|
export_csv: Scarica CSV
|
||||||
|
export_json: Scarica JSON
|
||||||
|
drawer:
|
||||||
|
maximize: Massimizza
|
||||||
|
restore: Ripristina
|
||||||
notifications:
|
notifications:
|
||||||
title: Notifiche
|
title: Notifiche
|
||||||
description: Configura dove e quando ricevere avvisi
|
description: Configura dove e quando ricevere avvisi
|
||||||
@@ -213,6 +226,8 @@ notifications:
|
|||||||
add-destination: Aggiungi destinazione
|
add-destination: Aggiungi destinazione
|
||||||
alerts: Avvisi
|
alerts: Avvisi
|
||||||
add-alert: Aggiungi avviso
|
add-alert: Aggiungi avviso
|
||||||
|
prefill-name: Avvisi di errore
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Aggiungi
|
add: Aggiungi
|
||||||
filter:
|
filter:
|
||||||
all: Tutti ({count})
|
all: Tutti ({count})
|
||||||
@@ -318,6 +333,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Inizia con le notifiche
|
title: Inizia con le notifiche
|
||||||
description: Scegli come vuoi ricevere avvisi quando i tuoi container richiedono attenzione.
|
description: Scegli come vuoi ricevere avvisi quando i tuoi container richiedono attenzione.
|
||||||
|
cloud-subtitle: Riepiloghi AI, controllo remoto e altro
|
||||||
|
webhook-subtitle: Invia avvisi a qualsiasi endpoint
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud Collegato
|
title: Dozzle Cloud Collegato
|
||||||
message: La tua istanza è stata connessa con successo. Puoi scollegarla nelle impostazioni o controllare il tuo utilizzo in qualsiasi momento.
|
message: La tua istanza è stata connessa con successo. Puoi scollegarla nelle impostazioni o controllare il tuo utilizzo in qualsiasi momento.
|
||||||
@@ -397,3 +414,19 @@ cloud-search:
|
|||||||
container-removed: "Il container è stato eliminato"
|
container-removed: "Il container è stato eliminato"
|
||||||
cta-settings: "Impostazioni Cloud"
|
cta-settings: "Impostazioni Cloud"
|
||||||
container-removed-pill: "eliminato"
|
container-removed-pill: "eliminato"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Comandi
|
||||||
|
restart-container: Riavvia {name}
|
||||||
|
stop-container: Ferma {name}
|
||||||
|
start-container: Avvia {name}
|
||||||
|
update-container: Aggiorna {name}
|
||||||
|
theme-auto: Usa tema di sistema
|
||||||
|
theme-light: Passa al tema chiaro
|
||||||
|
theme-dark: Passa al tema scuro
|
||||||
|
toggle-compact: Attiva/disattiva modalità compatta
|
||||||
|
toggle-timestamps: Attiva/disattiva timestamp
|
||||||
|
toggle-soft-wrap: Attiva/disattiva a capo automatico
|
||||||
|
toggle-stopped: Attiva/disattiva container fermati
|
||||||
|
toggle-std: Attiva/disattiva etichette stdout/stderr
|
||||||
|
toggle-scrollbars: Attiva/disattiva barre di scorrimento più piccole
|
||||||
|
open-settings: Apri impostazioni
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: 셸
|
shell: 셸
|
||||||
attach: 연결
|
attach: 연결
|
||||||
copy-permalink: 영구 링크 복사
|
copy-permalink: 영구 링크 복사
|
||||||
|
copy-image: "이미지 태그 복사"
|
||||||
copy-logs: 로그 복사
|
copy-logs: 로그 복사
|
||||||
copy-filtered-logs: 필터된 로그 복사
|
copy-filtered-logs: 필터된 로그 복사
|
||||||
copying-logs: 로그 복사 중...
|
copying-logs: 로그 복사 중...
|
||||||
@@ -216,6 +217,12 @@ analytics:
|
|||||||
example_all: 모든 로그
|
example_all: 모든 로그
|
||||||
example_count: 행 수
|
example_count: 행 수
|
||||||
example_group: "{column}별 개수"
|
example_group: "{column}별 개수"
|
||||||
|
export: 내보내기
|
||||||
|
export_csv: CSV 다운로드
|
||||||
|
export_json: JSON 다운로드
|
||||||
|
drawer:
|
||||||
|
maximize: 최대화
|
||||||
|
restore: 복원
|
||||||
notifications:
|
notifications:
|
||||||
title: 알림
|
title: 알림
|
||||||
description: 알림을 받을 위치와 시간을 설정합니다
|
description: 알림을 받을 위치와 시간을 설정합니다
|
||||||
@@ -411,3 +418,19 @@ cloud-search:
|
|||||||
container-removed: "컨테이너가 삭제되었습니다"
|
container-removed: "컨테이너가 삭제되었습니다"
|
||||||
cta-settings: "Cloud 설정"
|
cta-settings: "Cloud 설정"
|
||||||
container-removed-pill: "삭제됨"
|
container-removed-pill: "삭제됨"
|
||||||
|
command-palette:
|
||||||
|
section-commands: 명령
|
||||||
|
restart-container: "{name} 재시작"
|
||||||
|
stop-container: "{name} 중지"
|
||||||
|
start-container: "{name} 시작"
|
||||||
|
update-container: "{name} 업데이트"
|
||||||
|
theme-auto: 시스템 테마 사용
|
||||||
|
theme-light: 밝은 테마로 전환
|
||||||
|
theme-dark: 어두운 테마로 전환
|
||||||
|
toggle-compact: 컴팩트 모드 전환
|
||||||
|
toggle-timestamps: 타임스탬프 전환
|
||||||
|
toggle-soft-wrap: 자동 줄바꿈 전환
|
||||||
|
toggle-stopped: 중지된 컨테이너 전환
|
||||||
|
toggle-std: stdout/stderr 라벨 전환
|
||||||
|
toggle-scrollbars: 작은 스크롤바 전환
|
||||||
|
open-settings: 설정 열기
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ toolbar:
|
|||||||
shell: Shell
|
shell: Shell
|
||||||
attach: Koppelen
|
attach: Koppelen
|
||||||
copy-permalink: Permanente link kopiëren
|
copy-permalink: Permanente link kopiëren
|
||||||
|
copy-image: "Image-tag kopiëren"
|
||||||
copy-logs: Logs kopiëren
|
copy-logs: Logs kopiëren
|
||||||
copy-filtered-logs: Gefilterde logs kopiëren
|
copy-filtered-logs: Gefilterde logs kopiëren
|
||||||
copying-logs: Logs kopiëren...
|
copying-logs: Logs kopiëren...
|
||||||
@@ -39,6 +40,7 @@ label:
|
|||||||
running-containers: Actieve containers
|
running-containers: Actieve containers
|
||||||
all-containers: Alle containers
|
all-containers: Alle containers
|
||||||
all-namespaces: Alle Namespaces
|
all-namespaces: Alle Namespaces
|
||||||
|
namespaces: Namespaces
|
||||||
host: Host
|
host: Host
|
||||||
hosts: Hosts
|
hosts: Hosts
|
||||||
password: Wachtwoord
|
password: Wachtwoord
|
||||||
@@ -49,10 +51,13 @@ label:
|
|||||||
avg-cpu: Gem. CPU (%)
|
avg-cpu: Gem. CPU (%)
|
||||||
avg-mem: Gem. geheugen (%)
|
avg-mem: Gem. geheugen (%)
|
||||||
name: Naam
|
name: Naam
|
||||||
|
cpu: CPU
|
||||||
|
mem: MEM
|
||||||
pinned: Vastgezet
|
pinned: Vastgezet
|
||||||
per-page: Rijen per pagina
|
per-page: Rijen per pagina
|
||||||
host-menu: Hosts en Containers
|
host-menu: Hosts en Containers
|
||||||
swarm-menu: Services en Stacks
|
swarm-menu: Services en Stacks
|
||||||
|
k8s-menu: Kubernetes
|
||||||
group-menu: Aangepaste groepen
|
group-menu: Aangepaste groepen
|
||||||
no-logs: Container heeft nog geen logbestanden
|
no-logs: Container heeft nog geen logbestanden
|
||||||
search-status:
|
search-status:
|
||||||
@@ -113,6 +118,7 @@ title:
|
|||||||
login: Aanmelding vereist
|
login: Aanmelding vereist
|
||||||
dashboard: 1 container | {count} containers
|
dashboard: 1 container | {count} containers
|
||||||
settings: Instellingen
|
settings: Instellingen
|
||||||
|
notifications: Meldingen
|
||||||
button:
|
button:
|
||||||
logout: Afmelden
|
logout: Afmelden
|
||||||
login: Aanmelden
|
login: Aanmelden
|
||||||
@@ -131,6 +137,7 @@ settings:
|
|||||||
options-desc: Voorkeuren voor taal, navigatie en groepering.
|
options-desc: Voorkeuren voor taal, navigatie en groepering.
|
||||||
cloud-desc: Stream logs naar Dozzle Cloud voor AI-gestuurde analyses en zoeken over instanties heen.
|
cloud-desc: Stream logs naar Dozzle Cloud voor AI-gestuurde analyses en zoeken over instanties heen.
|
||||||
support-title: Steun Dozzle
|
support-title: Steun Dozzle
|
||||||
|
support-help: Donaties houden Dozzle gratis en onderhouden. Bedankt 🙏
|
||||||
display: Weergave
|
display: Weergave
|
||||||
locale: Taal aanpassen
|
locale: Taal aanpassen
|
||||||
small-scrollbars: Kleinere scrollbalk gebruiken
|
small-scrollbars: Kleinere scrollbalk gebruiken
|
||||||
@@ -207,6 +214,12 @@ analytics:
|
|||||||
example_all: Alle logs
|
example_all: Alle logs
|
||||||
example_count: Aantal rijen
|
example_count: Aantal rijen
|
||||||
example_group: Aantal per {column}
|
example_group: Aantal per {column}
|
||||||
|
export: Exporteren
|
||||||
|
export_csv: CSV downloaden
|
||||||
|
export_json: JSON downloaden
|
||||||
|
drawer:
|
||||||
|
maximize: Maximaliseren
|
||||||
|
restore: Herstellen
|
||||||
notifications:
|
notifications:
|
||||||
title: Meldingen
|
title: Meldingen
|
||||||
description: Configureer waar en wanneer je waarschuwingen ontvangt
|
description: Configureer waar en wanneer je waarschuwingen ontvangt
|
||||||
@@ -214,6 +227,8 @@ notifications:
|
|||||||
add-destination: Bestemming toevoegen
|
add-destination: Bestemming toevoegen
|
||||||
alerts: Waarschuwingen
|
alerts: Waarschuwingen
|
||||||
add-alert: Waarschuwing toevoegen
|
add-alert: Waarschuwing toevoegen
|
||||||
|
prefill-name: Foutwaarschuwingen
|
||||||
|
prefill-expression: level == "error"
|
||||||
add: Toevoegen
|
add: Toevoegen
|
||||||
filter:
|
filter:
|
||||||
all: Alle ({count})
|
all: Alle ({count})
|
||||||
@@ -319,6 +334,8 @@ notifications:
|
|||||||
empty-state:
|
empty-state:
|
||||||
title: Aan de slag met meldingen
|
title: Aan de slag met meldingen
|
||||||
description: Kies hoe je waarschuwingen wilt ontvangen wanneer je containers aandacht nodig hebben.
|
description: Kies hoe je waarschuwingen wilt ontvangen wanneer je containers aandacht nodig hebben.
|
||||||
|
cloud-subtitle: AI-samenvattingen, bediening op afstand en meer
|
||||||
|
webhook-subtitle: Stuur waarschuwingen naar elk eindpunt
|
||||||
cloud-link-success:
|
cloud-link-success:
|
||||||
title: Dozzle Cloud gekoppeld
|
title: Dozzle Cloud gekoppeld
|
||||||
message: Je instantie is succesvol verbonden. Je kunt de koppeling ongedaan maken in de instellingen of je gebruik op elk moment bekijken.
|
message: Je instantie is succesvol verbonden. Je kunt de koppeling ongedaan maken in de instellingen of je gebruik op elk moment bekijken.
|
||||||
@@ -398,3 +415,19 @@ cloud-search:
|
|||||||
container-removed: "Container is verwijderd"
|
container-removed: "Container is verwijderd"
|
||||||
cta-settings: "Cloud-instellingen"
|
cta-settings: "Cloud-instellingen"
|
||||||
container-removed-pill: "verwijderd"
|
container-removed-pill: "verwijderd"
|
||||||
|
command-palette:
|
||||||
|
section-commands: Opdrachten
|
||||||
|
restart-container: "{name} opnieuw starten"
|
||||||
|
stop-container: "{name} stoppen"
|
||||||
|
start-container: "{name} starten"
|
||||||
|
update-container: "{name} bijwerken"
|
||||||
|
theme-auto: Systeemthema gebruiken
|
||||||
|
theme-light: Overschakelen naar licht thema
|
||||||
|
theme-dark: Overschakelen naar donker thema
|
||||||
|
toggle-compact: Compacte modus omschakelen
|
||||||
|
toggle-timestamps: Tijdstempels omschakelen
|
||||||
|
toggle-soft-wrap: Zachte terugloop omschakelen
|
||||||
|
toggle-stopped: Gestopte containers omschakelen
|
||||||
|
toggle-std: stdout/stderr-labels omschakelen
|
||||||
|
toggle-scrollbars: Kleinere schuifbalken omschakelen
|
||||||
|
open-settings: Instellingen openen
|
||||||
|
|||||||