Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2eb5ec47 | |||
| 8358b98111 | |||
| af3aefe830 | |||
| ebc54a4481 | |||
| 8cf7ccd5ee | |||
| a9395e9f73 | |||
| b9df31356f | |||
| d2abd0e279 | |||
| 8a6ce3b21d | |||
| 239850c52d | |||
| 6b84105e79 | |||
| c159db5f0b | |||
| a18edf03ec | |||
| 18acf6722b | |||
| 5e0a4c43e6 | |||
| 48f04a0a7d | |||
| ae1753bcee | |||
| 3ae53dd719 | |||
| 3e0ed70362 | |||
| c6c64fdd86 | |||
| 616dd91483 | |||
| f952961eea | |||
| 3ca08f781b | |||
| 03011eea97 | |||
| 3783125b6a | |||
| fe6549e5f5 | |||
| cedb384dc4 | |||
| 163452a398 | |||
| f348b57721 | |||
| df5a60479c | |||
| 9c665d42a9 | |||
| 49f625a1db | |||
| af38e3a0c0 | |||
| 104749063d | |||
| d7b896e3d8 | |||
| 5693bcc4e4 | |||
| de70cbe389 | |||
| 73099aa67a | |||
| 9266f89776 | |||
| f88d7a1066 | |||
| f3e61606bd | |||
| 95794779d6 | |||
| e3f4566f6f | |||
| b86831d2ba | |||
| 4917984385 | |||
| c3b1c76151 | |||
| 7897a1555e | |||
| 523ac9f35e | |||
| 6e65b33161 | |||
| be2a9e0047 | |||
| fad2ea7763 | |||
| 4e7b6eea7b | |||
| a0704d5c7b | |||
| 63fa7c9e04 | |||
| 237e916390 | |||
| 6a16e35eb1 | |||
| 15b6abf843 | |||
| 540ace4e84 | |||
| c0a38587e2 | |||
| 2b1d9e889e | |||
| 5cd63429a9 | |||
| 820831b03d | |||
| 8cde0e7e48 | |||
| bd4651fb0b | |||
| 8d810b7a0b | |||
| c193e601f3 | |||
| 78ff45fdaf | |||
| ac42ce868a | |||
| 84ac9e51ac | |||
| 3062410b42 | |||
| beddc79892 | |||
| f7557ccbb5 | |||
| 6391853f3a | |||
| 619a844feb | |||
| e989caed33 | |||
| ad2b124a9e | |||
| 19c01e0fb4 | |||
| b38117bfae | |||
| 134f812336 | |||
| 55ab21b1bf | |||
| 853f46deb4 | |||
| ff0f855cf6 | |||
| 38b8511042 | |||
| 3c68345938 | |||
| acaec204b6 | |||
| 9b9c04498c | |||
| f13e169d10 | |||
| 2b23684dbd | |||
| 69b7ede7c4 | |||
| eb5c8fac1b | |||
| de368a1d97 | |||
| e283a3e361 | |||
| df11cc4740 | |||
| 791310a8b8 | |||
| d9c66f3786 | |||
| 2abcf3480e | |||
| fd0a4850f0 | |||
| 87bc48338d | |||
| 8811dc82bd | |||
| 51e13d5f97 | |||
| b37de0a16d | |||
| 743981b6e0 | |||
| c6a261c35d | |||
| 55a10273da | |||
| 38636d507f | |||
| 9ceb850d00 | |||
| c1d09ddcf1 | |||
| 62b5ef80ec | |||
| e54e14ba27 | |||
| 424abad60e | |||
| 6cdf81e4bb | |||
| 7bbddd90cb | |||
| c823617ff4 | |||
| 90ce6210ae | |||
| 21d6cd9922 |
@@ -3,5 +3,11 @@
|
||||
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump",
|
||||
"minimumReleaseAge": "24 hours",
|
||||
"ignoreDeps": []
|
||||
"ignoreDeps": [],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||
"groupName": "playwright"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -36,6 +36,7 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -23,14 +23,14 @@ jobs:
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -12,16 +12,19 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
@@ -31,12 +34,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.3
|
||||
go-version: 1.26.5
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
@@ -50,16 +53,19 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
@@ -93,20 +99,20 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -124,9 +130,10 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
push: true
|
||||
sbom: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -142,13 +149,11 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,20 +18,20 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4.2.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -24,14 +24,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
@@ -15,18 +15,17 @@ jobs:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -37,14 +36,17 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -56,12 +58,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.3"
|
||||
go-version: "1.26.5"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
@@ -77,11 +79,11 @@ jobs:
|
||||
name: Go Staticcheck
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.3"
|
||||
go-version: "1.26.5"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
@@ -94,18 +96,17 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -21,6 +21,10 @@ fake_assets:
|
||||
test: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 40s ./...
|
||||
|
||||
.PHONY: test-update
|
||||
test-update: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 5s ./... -- -- -u
|
||||
|
||||
.PHONY: build
|
||||
build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
@@ -17,6 +19,7 @@ declare global {
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const colorize: typeof import('./utils/index').colorize
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
@@ -66,8 +69,10 @@ declare global {
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const getDeep: typeof import('./utils/index').getDeep
|
||||
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
|
||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||
@@ -117,6 +122,7 @@ declare global {
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
@@ -213,6 +219,7 @@ declare global {
|
||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useCommands: typeof import('./composable/commands').useCommands
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||
@@ -417,10 +424,13 @@ declare global {
|
||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||
import('./composable/cloudLogSearch')
|
||||
// @ts-ignore
|
||||
export type { CommandSection, Command } from './composable/commands'
|
||||
import('./composable/commands')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
export type { LogStreamSource } from './composable/eventStreams'
|
||||
export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
|
||||
import('./composable/eventStreams')
|
||||
// @ts-ignore
|
||||
export type { ExprEditorOptions } from './composable/exprEditor'
|
||||
@@ -435,7 +445,7 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
@@ -458,6 +468,7 @@ declare module 'vue' {
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
||||
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
|
||||
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
@@ -506,8 +517,10 @@ declare module 'vue' {
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -556,6 +569,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
@@ -649,6 +663,7 @@ declare module 'vue' {
|
||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useCommands: UnwrapRef<typeof import('./composable/commands')['useCommands']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { build } from "vite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("production build", () => {
|
||||
it("emits font URLs relative to the generated stylesheet", async () => {
|
||||
const result = await build({
|
||||
configFile: "vite.config.ts",
|
||||
logLevel: "silent",
|
||||
build: { write: false },
|
||||
});
|
||||
const outputs = Array.isArray(result) ? result : [result];
|
||||
const stylesheet = outputs
|
||||
.flatMap((output) => ("output" in output ? output.output : []))
|
||||
.find((item) => item.type === "asset" && item.fileName.endsWith(".css"));
|
||||
|
||||
expect(stylesheet).toBeDefined();
|
||||
if (!stylesheet || stylesheet.type !== "asset") {
|
||||
throw new Error("Production build did not emit a stylesheet");
|
||||
}
|
||||
const css = String(stylesheet.source);
|
||||
expect(css).toMatch(/url\(\.\/jetbrains-mono/);
|
||||
expect(css).not.toMatch(/url\(\/assets\/jetbrains-mono/);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -105,6 +105,8 @@ declare module 'vue' {
|
||||
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowCollapse': typeof import('~icons/mdi/arrow-collapse')['default']
|
||||
'Mdi:arrowExpand': typeof import('~icons/mdi/arrow-expand')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
||||
@@ -169,6 +171,7 @@ declare module 'vue' {
|
||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||
'Ph:database': typeof import('~icons/ph/database')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:downloadSimple': typeof import('~icons/ph/download-simple')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
@@ -182,6 +185,7 @@ declare module 'vue' {
|
||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
|
||||
Search: typeof import('./components/Search.vue')['default']
|
||||
SearchStatus: typeof import('./components/LogViewer/SearchStatus.vue')['default']
|
||||
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
|
||||
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Container } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const {
|
||||
|
||||
@@ -199,7 +199,7 @@ const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
||||
|
||||
const router = useRouter();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { t } = useI18n();
|
||||
const { showToast, removeToast } = useToast();
|
||||
|
||||
@@ -366,10 +366,25 @@ a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
/* daisyUI's .menu is width: fit-content, so nested submenus (Streams, Levels)
|
||||
* shrink to their content and the hover highlight stops short. Stretch them to
|
||||
* fill the dropdown so the row highlight spans the full width. */
|
||||
.menu li ul {
|
||||
margin-inline-start: 0;
|
||||
width: 100%;
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the solid level colors, but use white labels in the light theme so the
|
||||
* text reads against the saturated chip backgrounds. warn is a light orange,
|
||||
* where dark text has better contrast than white, so it keeps the default. */
|
||||
[data-theme="light"] .badge[data-level="info"],
|
||||
[data-theme="light"] .badge[data-level="debug"],
|
||||
[data-theme="light"] .badge[data-level="trace"],
|
||||
[data-theme="light"] .badge[data-level="error"],
|
||||
[data-theme="light"] .badge[data-level="fatal"] {
|
||||
color: oklch(100% 0 0) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,8 +46,20 @@
|
||||
</div>
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<VolumeWarning :container="container" />
|
||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
<Tag
|
||||
class="group hidden! cursor-pointer items-center gap-1.5 pr-1! font-mono @md:inline-flex!"
|
||||
size="small"
|
||||
role="button"
|
||||
:title="$t('toolbar.copy-image')"
|
||||
:aria-label="$t('toolbar.copy-image')"
|
||||
@click="copyImage"
|
||||
>
|
||||
<span class="truncate">{{ imageTag }}</span>
|
||||
<span
|
||||
class="bg-base-content/10 text-base-content/40 group-hover:text-base-content/70 flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<mdi:content-copy class="size-3" />
|
||||
</span>
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,6 +68,21 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { showToast } = useToast();
|
||||
|
||||
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
|
||||
|
||||
async function copyImage() {
|
||||
if (!isSupported.value) return;
|
||||
await copy(imageTag.value);
|
||||
if (copied.value) {
|
||||
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.name),
|
||||
set: (value) => {
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
// @ts-ignore
|
||||
import PhWarning from "~icons/ph/warning-fill";
|
||||
|
||||
const WARN = 0.85;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
|
||||
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
||||
|
||||
import { Container } from "@/models/Container";
|
||||
import { lightTheme } from "@/stores/settings";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -16,7 +17,7 @@ vi.mock("vue-router");
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
@@ -119,4 +120,28 @@ describe("<FuzzySearchModal />", () => {
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
||||
});
|
||||
|
||||
test("matches commands by keyword", async () => {
|
||||
const wrapper = createFuzzySearchModal();
|
||||
await wrapper.find("input").setValue("theme");
|
||||
const items = wrapper.findAll("li").map((li) => li.text());
|
||||
expect(items).toContain("command-palette.theme-dark");
|
||||
});
|
||||
|
||||
test("theme commands set the theme explicitly", async () => {
|
||||
lightTheme.value = "auto";
|
||||
const wrapper = createFuzzySearchModal();
|
||||
|
||||
await wrapper.find("input").setValue("dark theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("dark");
|
||||
|
||||
await wrapper.find("input").setValue("light theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("light");
|
||||
|
||||
await wrapper.find("input").setValue("system theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
tabindex="0"
|
||||
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
||||
ref="input"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, totalCount - 1)"
|
||||
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||
@keydown.enter.exact="onEnter"
|
||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
||||
@keydown.alt.enter.exact.prevent="onPin"
|
||||
v-model="query"
|
||||
:placeholder="placeholderCopy"
|
||||
/>
|
||||
@@ -29,51 +29,80 @@
|
||||
|
||||
<!-- Body: results + log search CTA. Only renders when there is something
|
||||
to show — keeps the empty modal compact. -->
|
||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Containers section -->
|
||||
<template v-if="results.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
<div v-if="totalCount || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Scroll container spans both sections so the flat Commands + Containers
|
||||
list scrolls as one, matching the unified selection index. -->
|
||||
<div class="max-h-[50vh] overflow-y-auto overscroll-contain">
|
||||
<!-- Commands section -->
|
||||
<template v-if="commandEntries.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("command-palette.section-commands") }} · {{ commandEntries.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(command, index) in commandEntries" :ref="(el) => setItemRef(el, index)">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="runCommand(command)"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<component :is="command.icon" class="text-base-content/60 size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ command.title }}</span>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="text-base-content/40 size-4" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Containers section -->
|
||||
<template v-if="containerEntries.length">
|
||||
<div
|
||||
class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase"
|
||||
:class="{ 'border-base-content/10 mt-1 border-t': commandEntries.length }"
|
||||
>
|
||||
{{ $t("cloud-search.containers-section") }} · {{ containerEntries.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li
|
||||
v-for="(result, index) in containerEntries"
|
||||
:ref="(el) => setItemRef(el, commandEntries.length + index)"
|
||||
>
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': commandEntries.length + index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="commandEntries.length + index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Log search CTA -->
|
||||
<div
|
||||
v-if="logSearchVisible"
|
||||
@@ -130,7 +159,7 @@
|
||||
<div
|
||||
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
|
||||
>
|
||||
<span v-if="results.length" class="flex items-center gap-1.5">
|
||||
<span v-if="totalCount" class="flex items-center gap-1.5">
|
||||
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
||||
</span>
|
||||
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
||||
@@ -164,6 +193,7 @@ import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
import { type FuseResult } from "fuse.js";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||
import { useCommands, type Command } from "@/composable/commands";
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
@@ -176,9 +206,15 @@ const route = useRoute();
|
||||
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
||||
const query = ref(initialQuery);
|
||||
const input = ref<HTMLInputElement>();
|
||||
const listItems = ref<HTMLInputElement[]>();
|
||||
const listItems = ref<(Element | null)[]>([]);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
// Function ref into a single flat array so Commands and Containers share one
|
||||
// selection index for arrow-key navigation and scroll-into-view.
|
||||
function setItemRef(el: any, index: number) {
|
||||
listItems.value[index] = (el?.$el ?? el) as Element | null;
|
||||
}
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
@@ -268,6 +304,20 @@ const { results: fuseResults } = useFuse(query, list, {
|
||||
|
||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
||||
|
||||
// Commands palette. Fuzzy-matched against title/keywords while typing; the
|
||||
// context commands (container actions) show up front on an empty query.
|
||||
const { commands, contextCommands } = useCommands();
|
||||
const { results: commandFuseResults } = useFuse(query, commands, {
|
||||
fuseOptions: {
|
||||
keys: ["title", "keywords"],
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
},
|
||||
});
|
||||
const commandEntries = computed<Command[]>(() =>
|
||||
query.value ? commandFuseResults.value.map((r) => r.item) : contextCommands.value,
|
||||
);
|
||||
|
||||
const data = computed(() => {
|
||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||
if (a.score === b.score) {
|
||||
@@ -284,14 +334,26 @@ const data = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(query, (data) => {
|
||||
if (data.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
// Container hits, mirrors the previously named `data` list for the template.
|
||||
const containerEntries = computed(() => data.value);
|
||||
const totalCount = computed(() => commandEntries.value.length + containerEntries.value.length);
|
||||
|
||||
// Reset to the top only when the user types. Live SSE container add/remove
|
||||
// changes totalCount too, and resetting on that would snap the selection back
|
||||
// to 0 while the palette is open.
|
||||
watch(query, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
// Keep the selection in bounds when the result count shrinks underneath it.
|
||||
watch(totalCount, (count) => {
|
||||
if (selectedIndex.value > count - 1) {
|
||||
selectedIndex.value = Math.max(count - 1, 0);
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
||||
listItems.value?.[selectedIndex.value]?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
function selected(item: Item) {
|
||||
@@ -305,17 +367,35 @@ function selected(item: Item) {
|
||||
close();
|
||||
}
|
||||
|
||||
async function runCommand(command: Command) {
|
||||
close();
|
||||
await command.perform();
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
// Plain Enter prefers a container match if one is selected. With no
|
||||
// container matches (cloud-only query like "OOM"), fall back to log search
|
||||
// so the user isn't stuck on a popup that does nothing.
|
||||
if (data.value.length > 0) {
|
||||
selected(data.value[selectedIndex.value].item);
|
||||
// Commands come first in the flat list, then containers. With nothing
|
||||
// selectable (cloud-only query like "OOM"), fall back to log search so the
|
||||
// user isn't stuck on a popup that does nothing.
|
||||
const commandCount = commandEntries.value.length;
|
||||
if (selectedIndex.value < commandCount) {
|
||||
runCommand(commandEntries.value[selectedIndex.value]);
|
||||
} else if (containerEntries.value.length > 0) {
|
||||
selected(containerEntries.value[selectedIndex.value - commandCount].item);
|
||||
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
||||
runLogSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function onPin() {
|
||||
// Alt+Enter pins a container column. Only meaningful when a container row is
|
||||
// selected, not a command.
|
||||
const commandCount = commandEntries.value.length;
|
||||
if (selectedIndex.value >= commandCount) {
|
||||
const entry = containerEntries.value[selectedIndex.value - commandCount];
|
||||
if (entry?.item.type === "container") addColumn(entry.item);
|
||||
}
|
||||
}
|
||||
|
||||
function runLogSearch() {
|
||||
if (!cloudSearch.available.value) return;
|
||||
const q = query.value.trim();
|
||||
|
||||
@@ -65,9 +65,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Host } from "@/stores/hosts";
|
||||
import { Container } from "@/models/Container";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -54,10 +54,11 @@
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -89,10 +90,11 @@
|
||||
<li v-for="host in groupHosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -103,10 +105,11 @@
|
||||
<li v-for="host in groupHosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -147,7 +150,7 @@
|
||||
active-class="menu-active"
|
||||
@click.alt.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
:title="item.name"
|
||||
class="group auto-cols-[content_max_auto_max-content_max-content]"
|
||||
class="group auto-cols-[max-content_minmax(0,1fr)_max-content_max-content]"
|
||||
>
|
||||
<svg-spinners:ring-resize v-if="item.isNew" class="text-secondary w-2" />
|
||||
<div
|
||||
@@ -186,11 +189,8 @@ import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
import { showAllContainers, groupContainers } from "@/stores/settings";
|
||||
|
||||
// @ts-ignore
|
||||
import Pin from "~icons/ph/map-pin-simple";
|
||||
// @ts-ignore
|
||||
import Stack from "~icons/ph/stack";
|
||||
// @ts-ignore
|
||||
import Containers from "~icons/octicon/container-24";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
@@ -78,8 +78,8 @@
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in owners" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
@@ -95,8 +95,8 @@
|
||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<mdi:cog class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<dropdown class="dropdown-end" v-if="config.user">
|
||||
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
||||
<template #trigger>
|
||||
<template v-if="config.disableAvatars || !config.user.email">
|
||||
<material-symbols:person class="size-6" />
|
||||
|
||||
@@ -53,6 +53,11 @@ describe("<ComplexLogItem />", () => {
|
||||
expect(wrapper.find(".array").text()).toContain("b");
|
||||
});
|
||||
|
||||
test("renders null inside an array without throwing", () => {
|
||||
const wrapper = mountItem({ tags: ["a", null, "b"] } as any);
|
||||
expect(wrapper.find(".array").text()).toContain("null");
|
||||
});
|
||||
|
||||
test("shows a placeholder when every value is hidden", () => {
|
||||
const visibleKeys = ref(new Map<string[], boolean>([[["a"], false]]));
|
||||
const wrapper = mountItem({ a: 1 }, visibleKeys);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="key">{{ name }}=</span>
|
||||
<span class="value" v-if="value === null"><null></span>
|
||||
<ReuseTemplate :data="value" v-else-if="isObject(value) || Array.isArray(value)" />
|
||||
<span v-else class="value" :class="typeof value" v-html="stripAnsi(value.toString())"></span>
|
||||
<span v-else class="value" :class="typeof value" v-html="stripAnsi(String(value))"></span>
|
||||
</li>
|
||||
<li v-else-if="Array.isArray(data)">
|
||||
<ul class="array inline-flex flex-wrap space-x-1">
|
||||
@@ -15,7 +15,7 @@
|
||||
class="after:text-base-content/70 not-last:after:content-[',']"
|
||||
>
|
||||
<ReuseTemplate :data="item" v-if="isObject(item) || Array.isArray(item)" />
|
||||
<span v-else class="value" :class="typeof item" v-html="stripAnsi(item.toString())"></span>
|
||||
<span v-else class="value" :class="typeof item" v-html="stripAnsi(String(item))"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { default as Component } from "./EventSource.vue";
|
||||
import SearchStatus from "./SearchStatus.vue";
|
||||
import LogViewer from "@/components/LogViewer/LogViewer.vue";
|
||||
import { Container } from "@/models/Container";
|
||||
import { Level } from "@/models/LogEntry";
|
||||
@@ -172,6 +173,32 @@ describe("<ContainerEventSource />", () => {
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("search status", () => {
|
||||
test("shows no-logs when not searching and the stream is empty", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("suppresses no-logs while a search is still running", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emit("search-status", {
|
||||
data: JSON.stringify({ scannedTo: "2026-06-01T14:31:00Z", matches: 0, done: false }),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(false);
|
||||
expect(wrapper.findComponent(SearchStatus).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
|
||||
<SearchStatus :status="searchStatus" class="sticky top-0 z-10" />
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog && !inSearch)">
|
||||
<div class="flex flex-row gap-2" v-for="size in sizes">
|
||||
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
|
||||
<div class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ul>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">
|
||||
<div v-else-if="noLogs && !waitingForMoreLog && !inSearch" class="p-4" data-testid="no-logs">
|
||||
{{ $t("label.no-logs") }}
|
||||
</div>
|
||||
<slot :messages="messages" v-else></slot>
|
||||
@@ -24,7 +25,11 @@ const { entity, streamSource } = $defineProps<{
|
||||
|
||||
const { historical } = useLoggingContext();
|
||||
|
||||
const { messages, opened, loading, error } = streamSource(toRef(() => entity));
|
||||
const { messages, opened, loading, error, searchStatus } = streamSource(toRef(() => entity));
|
||||
|
||||
// While a search is running (or just finished), SearchStatus owns the empty
|
||||
// messaging, so suppress the generic "no logs" state to avoid the false signal.
|
||||
const inSearch = computed(() => searchStatus.value.active || searchStatus.value.done);
|
||||
|
||||
const color = computed(() => {
|
||||
if (error.value) return "error";
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: GroupedLogEntry;
|
||||
@@ -32,6 +25,4 @@ const getPosition = (index: number): Position => {
|
||||
if (index === len - 1) return "end";
|
||||
return "middle";
|
||||
};
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
|
||||
@@ -18,13 +18,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// @ts-ignore
|
||||
import PhNetwork from "~icons/ph/network";
|
||||
// @ts-ignore
|
||||
import PhHardDrives from "~icons/ph/hard-drives";
|
||||
// @ts-ignore
|
||||
import PhArrowUp from "~icons/ph/arrow-up";
|
||||
// @ts-ignore
|
||||
import PhArrowDown from "~icons/ph/arrow-down";
|
||||
|
||||
const { networkRx, networkTx, diskRead, diskWrite } = defineProps<{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@mouseenter="checkDropdownPosition"
|
||||
>
|
||||
<router-link
|
||||
v-if="isSearching"
|
||||
v-if="isFiltered"
|
||||
@click="resetSearch()"
|
||||
tabindex="0"
|
||||
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
|
||||
@@ -31,7 +31,7 @@
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="isSearching">
|
||||
<li v-if="isFiltered">
|
||||
<router-link
|
||||
@click="resetSearch()"
|
||||
:to="{
|
||||
@@ -99,8 +99,13 @@ const { showToast } = useToast();
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
const { isSearching, resetSearch } = useSearchFilter();
|
||||
const { levels } = useLoggingContext();
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
// Show "see in context" whenever the stream is narrowed, either by a text search
|
||||
// or by a log-level filter, so the entry can be inspected in the full log stream.
|
||||
const isFiltered = computed(() => isSearching.value || allLevels.some((level) => !levels.value.has(level)));
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard({ legacy: true });
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessage() {
|
||||
|
||||
@@ -48,6 +48,25 @@
|
||||
}}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end shrink-0" v-if="canExport">
|
||||
<div tabindex="0" role="button" class="btn btn-xs btn-ghost cursor-pointer gap-1">
|
||||
<ph:download-simple class="size-4" />
|
||||
{{ $t("analytics.export") }}
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-30 w-44 p-2 shadow-sm">
|
||||
<li>
|
||||
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('csv')">{{
|
||||
$t("analytics.export_csv")
|
||||
}}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('json')">{{
|
||||
$t("analytics.export_json")
|
||||
}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -238,5 +257,54 @@ whenever(evaluating, () => {
|
||||
const page = computed(() =>
|
||||
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
||||
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
||||
|
||||
const canExport = computed(() => state.value === "ready" && !evaluating.value && results.value.numRows > 0);
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "object") return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toCSV(table: Table<Record<string, any>>, columns: string[]): string {
|
||||
const escape = (value: string) => (/[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
|
||||
const lines = [columns.map(escape).join(",")];
|
||||
for (const row of table) {
|
||||
lines.push(columns.map((column) => escape(stringify((row as Record<string, any>)[column]))).join(","));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toJSON(table: Table<Record<string, any>>, columns: string[]): string {
|
||||
const rows = [];
|
||||
for (const row of table) {
|
||||
const record: Record<string, unknown> = {};
|
||||
for (const column of columns) {
|
||||
const value = (row as Record<string, any>)[column];
|
||||
record[column] = typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
rows.push(record);
|
||||
}
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}
|
||||
|
||||
function exportResults(format: "csv" | "json") {
|
||||
const table = results.value as unknown as Table<Record<string, any>>;
|
||||
if (table.numRows === 0) return;
|
||||
|
||||
const columns = Object.keys(table.get(0) as Record<string, any>);
|
||||
const content = format === "csv" ? toCSV(table, columns) : toJSON(table, columns);
|
||||
const type = format === "csv" ? "text/csv;charset=utf-8" : "application/json";
|
||||
const name = container.name.replace(/[^\w.-]+/g, "-");
|
||||
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${name}-query.${format}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -66,9 +66,7 @@ import { Container, Stat, emptyStat } from "@/models/Container";
|
||||
import StatCard from "@/components/LogViewer/StatCard.vue";
|
||||
import IOCard from "@/components/LogViewer/IOCard.vue";
|
||||
import BarChart from "@/components/BarChart.vue";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const { containers } = defineProps<{
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import SearchStatus from "./SearchStatus.vue";
|
||||
import IndeterminateBar from "@/components/common/IndeterminateBar.vue";
|
||||
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
label: {
|
||||
"search-status": {
|
||||
searching: "Searching older logs…",
|
||||
"searching-to": "Searching older logs… back to {time}",
|
||||
capped: "{count} matches · searched back to {time}",
|
||||
exhausted: "Searched all logs · {count} matches",
|
||||
empty: "No matches · searched all logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function createStatus(overrides: Record<string, unknown> = {}) {
|
||||
return mount(SearchStatus, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
status: { active: false, done: false, matches: 0, scannedTo: undefined, reason: undefined, ...overrides },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("<SearchStatus />", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("stays hidden while a search is active but still fast (flash avoidance)", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(wrapper.find("[data-state]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("shows searching state once a search runs past the reveal delay", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent(IndeterminateBar).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("reveals the searching bar even when progress events arrive faster than the delay", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
// a slow search emits a progress event each window; the reveal delay must
|
||||
// measure from when the search started, not restart on every event
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(100);
|
||||
await wrapper.setProps({ status: { active: true, done: false, matches: i, scannedTo: `t${i}` } });
|
||||
await nextTick();
|
||||
}
|
||||
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("shows the empty state when a search finishes with no matches", async () => {
|
||||
const wrapper = createStatus({ active: false, done: true, matches: 0, reason: "exhausted" });
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="empty"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("shows a completion summary for a slow exhausted search", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
await wrapper.setProps({ status: { active: false, done: true, matches: 3, reason: "exhausted" } });
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="exhausted"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
|
||||
test("shows a capped summary for a slow capped search", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
await wrapper.setProps({
|
||||
status: { active: false, done: true, matches: 50, reason: "capped", scannedTo: "2026-06-01T13:10:00Z" },
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="capped"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("stays quiet for a fast search that returned matches", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
await wrapper.setProps({ status: { active: false, done: true, matches: 5, reason: "capped" } });
|
||||
await nextTick();
|
||||
expect(wrapper.find("[data-state]").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="state"
|
||||
:data-state="state"
|
||||
class="bg-base-200/80 text-base-content/70 flex items-center gap-2 px-4 py-1.5 text-xs backdrop-blur"
|
||||
>
|
||||
<template v-if="state === 'searching'">
|
||||
<span>{{
|
||||
status.scannedTo ? $t("label.search-status.searching-to", { time }) : $t("label.search-status.searching")
|
||||
}}</span>
|
||||
<IndeterminateBar color="primary" class="ml-auto" />
|
||||
</template>
|
||||
<span v-else-if="state === 'empty'">{{ $t("label.search-status.empty") }}</span>
|
||||
<span v-else-if="state === 'capped'" class="tabular-nums">
|
||||
{{ $t("label.search-status.capped", { count: status.matches, time }) }}
|
||||
</span>
|
||||
<span v-else-if="state === 'exhausted'" class="tabular-nums">
|
||||
{{ $t("label.search-status.exhausted", { count: status.matches }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type SearchStatus } from "@/composable/eventStreams";
|
||||
|
||||
const props = defineProps<{ status: SearchStatus }>();
|
||||
|
||||
// Reveal the in-progress bar only after a short delay so fast searches (the
|
||||
// common case, which return almost instantly) never flash it. Slow searches
|
||||
// — sparse matches over a large log — are the only ones that surface it.
|
||||
const showSearching = ref(false);
|
||||
// Remember whether this search ever ran slow, so the completion summary only
|
||||
// shows for searches that actually made the user wait.
|
||||
const wasSlow = ref(false);
|
||||
|
||||
// Watch the boolean, not the whole status object: a slow search replaces the
|
||||
// status object on every progress event, and re-arming the timer each time
|
||||
// would keep the bar from ever appearing. The delay must measure from when the
|
||||
// search started.
|
||||
const active = computed(() => props.status.active);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(
|
||||
active,
|
||||
(isActive) => {
|
||||
clearTimeout(timer);
|
||||
if (isActive) {
|
||||
timer = setTimeout(() => {
|
||||
showSearching.value = true;
|
||||
wasSlow.value = true;
|
||||
}, 400);
|
||||
} else {
|
||||
showSearching.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onScopeDispose(() => clearTimeout(timer));
|
||||
|
||||
const time = computed(() => (props.status.scannedTo ? new Date(props.status.scannedTo).toLocaleString() : ""));
|
||||
|
||||
const state = computed<"searching" | "empty" | "capped" | "exhausted" | null>(() => {
|
||||
if (showSearching.value) return "searching";
|
||||
if (!props.status.active && props.status.done) {
|
||||
if (props.status.matches === 0) return "empty";
|
||||
if (wasSlow.value) return props.status.reason === "capped" ? "capped" : "exhausted";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
@@ -9,17 +9,8 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
}>();
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
|
||||
@@ -7,17 +7,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
@@ -48,17 +48,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
@@ -89,17 +89,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<mdi:chart-line v-if="alert.metricExpression" class="text-info" />
|
||||
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info" />
|
||||
<mdi:text-box-outline v-else class="text-info" />
|
||||
<span>{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h4 class="flex min-w-0 flex-wrap items-center gap-2 text-lg font-semibold">
|
||||
<mdi:chart-line v-if="alert.metricExpression" class="text-info shrink-0" />
|
||||
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info shrink-0" />
|
||||
<mdi:text-box-outline v-else class="text-info shrink-0" />
|
||||
<span class="break-all">{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<div class="group/dispatch dropdown dropdown-hover">
|
||||
<div
|
||||
tabindex="0"
|
||||
@@ -48,7 +48,12 @@
|
||||
</h4>
|
||||
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
|
||||
</div>
|
||||
<input type="checkbox" class="toggle toggle-primary" :checked="alert.enabled" @change="toggleEnabled" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary shrink-0"
|
||||
:checked="alert.enabled"
|
||||
@change="toggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Expressions -->
|
||||
@@ -78,8 +83,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-content/10 text-base-content/80 flex items-center justify-between border-t pt-3 text-xs">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="border-base-content/10 text-base-content/80 flex items-center justify-between gap-2 border-t pt-3 text-xs"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span>
|
||||
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
|
||||
</span>
|
||||
@@ -90,7 +97,7 @@
|
||||
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button class="btn btn-ghost btn-square" @click="editAlert">
|
||||
<mdi:pencil-outline />
|
||||
</button>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.event-fields-hint", {
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, exitCode, signal, etc.)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
data-testid="scrollable-header"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[var(--mobile-nav-height)] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("<WelcomeModal /> Create First Alert", () => {
|
||||
|
||||
const bodies = ruleCalls.map((c) => JSON.parse((c[1] as RequestInit).body as string));
|
||||
const eventExpressions = bodies.map((b) => b.eventExpression).filter(Boolean);
|
||||
expect(eventExpressions).toContain('name == "die" && attributes["exitCode"] != "0"');
|
||||
expect(eventExpressions).toContain('name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])');
|
||||
expect(eventExpressions).toContain('name == "health_status" && attributes["healthStatus"] == "unhealthy"');
|
||||
expect(eventExpressions).toContain('name == "oom"');
|
||||
expect(eventExpressions).not.toContain('name == "restart"');
|
||||
|
||||
@@ -132,7 +132,10 @@ const signals = computed<SignalDef[]>(() => [
|
||||
label: t("cloud.welcome.signals.exited"),
|
||||
description: t("cloud.welcome.signals.exited-desc"),
|
||||
ruleName: "Container exited with an error",
|
||||
expression: 'name == "die" && attributes["exitCode"] != "0"',
|
||||
// Ignore clean/graceful shutdowns: 0 (success), 130 (SIGINT), 143 (SIGTERM), 137 (SIGKILL).
|
||||
// These commonly fire on `docker stop`, Ctrl+C, and Watchtower update cycles, which are
|
||||
// not errors. Still alerts on genuine error exits (1, 2, 125, ...) and crash-loops.
|
||||
expression: 'name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])',
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
|
||||
<div class="p-2">
|
||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: '/' }">
|
||||
<Logo class="h-10" />
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
<template>
|
||||
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
||||
<div class="modal-box" :width="width">
|
||||
<div class="modal-box" :width="maximized ? 'full' : width">
|
||||
<div class="pt-safe relative">
|
||||
<form method="dialog" class="absolute right-0">
|
||||
<button v-if="isMobile">
|
||||
<mdi:close />
|
||||
<div class="absolute right-0 flex items-center gap-3">
|
||||
<button
|
||||
v-if="!isMobile"
|
||||
class="hover:text-base-content/60 cursor-pointer outline-hidden"
|
||||
type="button"
|
||||
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||
@click="maximized = !maximized"
|
||||
>
|
||||
<mdi:arrow-collapse v-if="maximized" />
|
||||
<mdi:arrow-expand v-else />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
<form method="dialog">
|
||||
<button v-if="isMobile" class="cursor-pointer">
|
||||
<mdi:close />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active cursor-pointer outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<slot v-if="open" :close="close"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,6 +37,7 @@ import { type DrawerWidth } from "@/composable/drawer";
|
||||
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
||||
|
||||
const open = ref(false);
|
||||
const maximized = ref(false);
|
||||
const { width } = defineProps<{
|
||||
width: DrawerWidth;
|
||||
}>();
|
||||
@@ -35,6 +49,7 @@ function close() {
|
||||
defineExpose({
|
||||
open: () => {
|
||||
open.value = true;
|
||||
maximized.value = false;
|
||||
panel.value?.showModal();
|
||||
},
|
||||
close,
|
||||
@@ -55,6 +70,10 @@ useEventListener(panel, "close", () => (open.value = false));
|
||||
&[width="lg"] {
|
||||
@apply max-w-5xl;
|
||||
}
|
||||
|
||||
&[width="full"] {
|
||||
@apply w-full max-w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-right[open] .modal-box {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Component } from "vue";
|
||||
import { Container } from "@/models/Container";
|
||||
import { useContainerActions } from "@/composable/containerActions";
|
||||
import config from "@/stores/config";
|
||||
import {
|
||||
lightTheme,
|
||||
compact,
|
||||
showTimestamp,
|
||||
softWrap,
|
||||
showAllContainers,
|
||||
showStd,
|
||||
smallerScrollbars,
|
||||
} from "@/stores/settings";
|
||||
|
||||
import mdiThemeLightDark from "~icons/mdi/theme-light-dark";
|
||||
import mdiWhiteBalanceSunny from "~icons/mdi/white-balance-sunny";
|
||||
import mdiWeatherNight from "~icons/mdi/weather-night";
|
||||
import mdiFormatLineSpacing from "~icons/mdi/format-line-spacing";
|
||||
import mdiClockOutline from "~icons/mdi/clock-outline";
|
||||
import mdiWrap from "~icons/mdi/wrap";
|
||||
import mdiEyeOutline from "~icons/mdi/eye-outline";
|
||||
import mdiFormatListBulleted from "~icons/mdi/format-list-bulleted";
|
||||
import mdiUnfoldMoreHorizontal from "~icons/mdi/unfold-more-horizontal";
|
||||
import mdiCogOutline from "~icons/mdi/cog-outline";
|
||||
import carbonRestart from "~icons/carbon/restart";
|
||||
import mdiStop from "~icons/mdi/stop";
|
||||
import mdiPlay from "~icons/mdi/play";
|
||||
import mdiDownload from "~icons/mdi/download";
|
||||
|
||||
export type CommandSection = "container" | "settings" | "navigation";
|
||||
|
||||
export type Command = {
|
||||
id: string;
|
||||
title: string;
|
||||
section: CommandSection;
|
||||
icon: Component;
|
||||
keywords?: string;
|
||||
perform: () => unknown;
|
||||
};
|
||||
|
||||
// Central registry for the Cmd+K command palette. Commands are recomputed on
|
||||
// every access so context-sensitive entries (container actions, current
|
||||
// toggle labels) stay in sync with the route and settings.
|
||||
export function useCommands() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
const currentId = computed(() =>
|
||||
route?.name === "/container/[id]" && typeof route.params.id === "string" ? route.params.id : "",
|
||||
);
|
||||
// Null-safe: containerStore.currentContainer is a stubbed action under
|
||||
// @pinia/testing, so guard against it being absent.
|
||||
const currentContainerRef = containerStore.currentContainer?.(currentId);
|
||||
const currentContainer = computed(() => currentContainerRef?.value as Container | undefined);
|
||||
|
||||
// Bound to the current container. The cast is safe because the action
|
||||
// handlers only read container.value when invoked, and container commands are
|
||||
// only pushed into the list when currentContainer is truthy — so the handlers
|
||||
// never run against an undefined container.
|
||||
const { start, stop, restart, update } = useContainerActions(currentContainer as Ref<Container>);
|
||||
|
||||
const commands = computed<Command[]>(() => {
|
||||
const list: Command[] = [];
|
||||
|
||||
const container = currentContainer.value;
|
||||
if (container && config.enableActions) {
|
||||
const name = container.name;
|
||||
list.push({
|
||||
id: "container.restart",
|
||||
section: "container",
|
||||
icon: carbonRestart,
|
||||
title: t("command-palette.restart-container", { name }),
|
||||
keywords: "restart reboot",
|
||||
perform: restart,
|
||||
});
|
||||
if (container.state === "running") {
|
||||
list.push({
|
||||
id: "container.stop",
|
||||
section: "container",
|
||||
icon: mdiStop,
|
||||
title: t("command-palette.stop-container", { name }),
|
||||
keywords: "stop kill halt",
|
||||
perform: stop,
|
||||
});
|
||||
} else {
|
||||
list.push({
|
||||
id: "container.start",
|
||||
section: "container",
|
||||
icon: mdiPlay,
|
||||
title: t("command-palette.start-container", { name }),
|
||||
keywords: "start run",
|
||||
perform: start,
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
id: "container.update",
|
||||
section: "container",
|
||||
icon: mdiDownload,
|
||||
title: t("command-palette.update-container", { name }),
|
||||
keywords: "update pull recreate upgrade",
|
||||
perform: update,
|
||||
});
|
||||
}
|
||||
|
||||
list.push(
|
||||
// lightTheme is tri-state, so expose each value as its own command rather
|
||||
// than a single toggle — that keeps "auto" (follow OS) reachable and makes
|
||||
// the target theme explicit instead of depending on the current state.
|
||||
{
|
||||
id: "settings.theme-auto",
|
||||
section: "settings",
|
||||
icon: mdiThemeLightDark,
|
||||
title: t("command-palette.theme-auto"),
|
||||
keywords: "theme auto system color mode appearance",
|
||||
perform: () => (lightTheme.value = "auto"),
|
||||
},
|
||||
{
|
||||
id: "settings.theme-light",
|
||||
section: "settings",
|
||||
icon: mdiWhiteBalanceSunny,
|
||||
title: t("command-palette.theme-light"),
|
||||
keywords: "theme light color mode appearance",
|
||||
perform: () => (lightTheme.value = "light"),
|
||||
},
|
||||
{
|
||||
id: "settings.theme-dark",
|
||||
section: "settings",
|
||||
icon: mdiWeatherNight,
|
||||
title: t("command-palette.theme-dark"),
|
||||
keywords: "theme dark color mode appearance",
|
||||
perform: () => (lightTheme.value = "dark"),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-compact",
|
||||
section: "settings",
|
||||
icon: mdiFormatLineSpacing,
|
||||
title: t("command-palette.toggle-compact"),
|
||||
keywords: "compact density spacing",
|
||||
perform: () => (compact.value = !compact.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-timestamps",
|
||||
section: "settings",
|
||||
icon: mdiClockOutline,
|
||||
title: t("command-palette.toggle-timestamps"),
|
||||
keywords: "timestamp time date",
|
||||
perform: () => (showTimestamp.value = !showTimestamp.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-soft-wrap",
|
||||
section: "settings",
|
||||
icon: mdiWrap,
|
||||
title: t("command-palette.toggle-soft-wrap"),
|
||||
keywords: "wrap soft line",
|
||||
perform: () => (softWrap.value = !softWrap.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-stopped",
|
||||
section: "settings",
|
||||
icon: mdiEyeOutline,
|
||||
title: t("command-palette.toggle-stopped"),
|
||||
keywords: "stopped hidden all containers exited",
|
||||
perform: () => (showAllContainers.value = !showAllContainers.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-std",
|
||||
section: "settings",
|
||||
icon: mdiFormatListBulleted,
|
||||
title: t("command-palette.toggle-std"),
|
||||
keywords: "stdout stderr std labels stream",
|
||||
perform: () => (showStd.value = !showStd.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-scrollbars",
|
||||
section: "settings",
|
||||
icon: mdiUnfoldMoreHorizontal,
|
||||
title: t("command-palette.toggle-scrollbars"),
|
||||
keywords: "scrollbar smaller thin",
|
||||
perform: () => (smallerScrollbars.value = !smallerScrollbars.value),
|
||||
},
|
||||
{
|
||||
id: "navigation.settings",
|
||||
section: "navigation",
|
||||
icon: mdiCogOutline,
|
||||
title: t("command-palette.open-settings"),
|
||||
keywords: "settings preferences options config",
|
||||
perform: () => router.push("/settings"),
|
||||
},
|
||||
);
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
// Commands shown before the user types anything: the context-sensitive
|
||||
// container actions so e.g. Restart is one keystroke away on a container page.
|
||||
const contextCommands = computed(() => commands.value.filter((c) => c.section === "container"));
|
||||
|
||||
return { commands, contextCommands };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Service, Stack } from "@/models/Stack";
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
import { parseMessage } from "@/composable/loadBetween";
|
||||
import { useLogLoader } from "@/composable/logLoader";
|
||||
import { parseEventData } from "@/utils/events";
|
||||
|
||||
const { isSearching, debouncedSearchFilter, inverseFilter } = useSearchFilter();
|
||||
|
||||
@@ -56,15 +57,23 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
|
||||
}
|
||||
|
||||
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
|
||||
const labels = computed(() => `namespace:${namespace.value.name}`);
|
||||
const labels = computed(() => `@k8s.namespace:${namespace.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
|
||||
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
|
||||
export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
|
||||
const labels = computed(() => `${owner.value.label}:true`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export type SearchStatus = {
|
||||
active: boolean;
|
||||
done: boolean;
|
||||
matches: number;
|
||||
scannedTo?: string;
|
||||
reason?: "capped" | "exhausted";
|
||||
};
|
||||
|
||||
export type LogStreamSource = ReturnType<typeof useLogStream>;
|
||||
|
||||
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
@@ -73,6 +82,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
|
||||
const { paused: scrollingPaused } = useScrollContext();
|
||||
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
|
||||
let initial = true;
|
||||
@@ -158,13 +168,14 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
initial = true;
|
||||
searchStatus.value = { active: isSearching.value, done: false, matches: 0 };
|
||||
es = new EventSource(urlWithParams.value);
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as {
|
||||
const event = parseEventData<{
|
||||
actorId: string;
|
||||
name: "container-stopped" | "container-started";
|
||||
time: string;
|
||||
};
|
||||
}>(e);
|
||||
const containerEvent = new ContainerEventLogEntry(
|
||||
event.name == "container-started" ? "Container started" : "Container stopped",
|
||||
event.actorId,
|
||||
@@ -178,11 +189,27 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
});
|
||||
|
||||
es.addEventListener("logs-backfill", (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data) as LogEvent[];
|
||||
const data = parseEventData<LogEvent[]>(e);
|
||||
const logs = data.map((e) => asLogEntry(e));
|
||||
messages.value = [...logs, ...messages.value];
|
||||
});
|
||||
|
||||
es.addEventListener("search-status", (e) => {
|
||||
const data = parseEventData<{
|
||||
scannedTo: string;
|
||||
matches: number;
|
||||
done: boolean;
|
||||
reason?: "capped" | "exhausted";
|
||||
}>(e);
|
||||
searchStatus.value = {
|
||||
active: !data.done,
|
||||
done: data.done,
|
||||
matches: data.matches,
|
||||
scannedTo: data.scannedTo,
|
||||
reason: data.reason,
|
||||
};
|
||||
});
|
||||
|
||||
es.onmessage = (e) => {
|
||||
if (e.data) {
|
||||
buffer.value = [...buffer.value, parseMessage(e.data)];
|
||||
@@ -214,5 +241,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
searchStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete";
|
||||
|
||||
export interface ExprEditorOptions {
|
||||
parent: HTMLElement;
|
||||
@@ -44,9 +44,9 @@ export function createContainerHints(
|
||||
{ label: '"healthy"', detail: "health value", type: "string" },
|
||||
{ label: '"unhealthy"', detail: "health value", type: "string" },
|
||||
{ label: '"none"', detail: "health value", type: "string" },
|
||||
...containerNames.map((name) => ({ label: `"${name}"`, detail: "container name", type: "string" }) as Completion),
|
||||
...imageNames.map((image) => ({ label: `"${image}"`, detail: "image name", type: "string" }) as Completion),
|
||||
...hostNames.map((host) => ({ label: `"${host}"`, detail: "host name", type: "string" }) as Completion),
|
||||
...containerNames.map((name): Completion => ({ label: `"${name}"`, detail: "container name", type: "string" })),
|
||||
...imageNames.map((image): Completion => ({ label: `"${image}"`, detail: "image name", type: "string" })),
|
||||
...hostNames.map((host): Completion => ({ label: `"${host}"`, detail: "host name", type: "string" })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createLogHints(messageKeys?: string[]): Completion[] {
|
||||
{ label: "timestamp", detail: "unix timestamp", type: "property" },
|
||||
{ label: "id", detail: "log entry ID", type: "property" },
|
||||
...(messageKeys ?? []).map(
|
||||
(key) => ({ label: `message.${key}`, detail: "message field", type: "property" }) as Completion,
|
||||
(key): Completion => ({ label: `message.${key}`, detail: "message field", type: "property" }),
|
||||
),
|
||||
...exprOperators,
|
||||
{ label: '"error"', detail: "level value", type: "string" },
|
||||
@@ -106,6 +106,8 @@ export function createEventHints(): Completion[] {
|
||||
return [
|
||||
{ label: "name", detail: "event name", type: "property" },
|
||||
{ label: "attributes", detail: "event attributes map", type: "property" },
|
||||
{ label: 'attributes["healthStatus"]', detail: "healthy or unhealthy (health_status events)", type: "property" },
|
||||
{ label: 'attributes["exitCode"]', detail: "exit code (die events)", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: '"start"', detail: "container started", type: "string" },
|
||||
{ label: '"stop"', detail: "container stopped", type: "string" },
|
||||
@@ -114,12 +116,18 @@ export function createEventHints(): Completion[] {
|
||||
{ label: '"health_status"', detail: "health check changed", type: "string" },
|
||||
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
|
||||
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
|
||||
{
|
||||
label: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
|
||||
detail: "match unhealthy containers",
|
||||
type: "text",
|
||||
boost: 10,
|
||||
},
|
||||
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
function createAutocomplete(getHints: () => Completion[]) {
|
||||
return (context: any) => {
|
||||
function createAutocomplete(getHints: () => Completion[]): CompletionSource {
|
||||
return (context: CompletionContext) => {
|
||||
const word = context.matchBefore(/[\w"=!&|]+/);
|
||||
if (!word && !context.explicit) return null;
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
// Historical views are a fixed window around a log id, never a running search.
|
||||
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
|
||||
const container = toRef(() => historicalContainer.value.container);
|
||||
|
||||
const { streamConfig, levels, loadingMore } = useLoggingContext();
|
||||
@@ -125,5 +127,6 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
searchStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<div class="modal-box max-w-2xl overflow-visible! bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
@@ -52,7 +52,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// @ts-ignore - splitpanes types are not available
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { collapseNav } from "@/stores/settings";
|
||||
import SideDrawer from "@/components/common/SideDrawer.vue";
|
||||
@@ -101,7 +100,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
padding-top: var(--mobile-nav-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,6 +57,24 @@
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
/* ANSI log palette tuned for legibility on the dark background */
|
||||
--ansi-black: oklch(45% 0 0);
|
||||
--ansi-red: oklch(65% 0.2 25);
|
||||
--ansi-green: oklch(72% 0.16 150);
|
||||
--ansi-yellow: oklch(80% 0.13 85);
|
||||
--ansi-blue: oklch(70% 0.14 250);
|
||||
--ansi-magenta: oklch(68% 0.19 330);
|
||||
--ansi-cyan: oklch(75% 0.11 200);
|
||||
--ansi-white: oklch(89% 0 0);
|
||||
--ansi-bright-black: oklch(60% 0 0);
|
||||
--ansi-bright-red: oklch(72% 0.2 25);
|
||||
--ansi-bright-green: oklch(82% 0.17 150);
|
||||
--ansi-bright-yellow: oklch(88% 0.14 95);
|
||||
--ansi-bright-blue: oklch(78% 0.13 250);
|
||||
--ansi-bright-magenta: oklch(75% 0.19 330);
|
||||
--ansi-bright-cyan: oklch(85% 0.11 200);
|
||||
--ansi-bright-white: oklch(100% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
@@ -97,6 +115,24 @@
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
/* ANSI log palette tuned for legibility on the light background */
|
||||
--ansi-black: oklch(25% 0 0);
|
||||
--ansi-red: oklch(52% 0.2 25);
|
||||
--ansi-green: oklch(52% 0.15 150);
|
||||
--ansi-yellow: oklch(60% 0.13 75);
|
||||
--ansi-blue: oklch(50% 0.17 255);
|
||||
--ansi-magenta: oklch(50% 0.2 330);
|
||||
--ansi-cyan: oklch(55% 0.1 210);
|
||||
--ansi-white: oklch(70% 0 0);
|
||||
--ansi-bright-black: oklch(45% 0 0);
|
||||
--ansi-bright-red: oklch(58% 0.21 25);
|
||||
--ansi-bright-green: oklch(58% 0.16 150);
|
||||
--ansi-bright-yellow: oklch(62% 0.13 85);
|
||||
--ansi-bright-blue: oklch(55% 0.16 255);
|
||||
--ansi-bright-magenta: oklch(56% 0.2 330);
|
||||
--ansi-bright-cyan: oklch(60% 0.1 210);
|
||||
--ansi-bright-white: oklch(85% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
@@ -120,6 +156,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* daisyUI darkens the ghost button's border on hover, which reads as a
|
||||
* near-black ring on the dark theme. Use a theme-aware base-content tint so the
|
||||
* hover ring stays visible and legible in both themes (light on dark, dark on
|
||||
* light) instead of a fixed near-black. */
|
||||
.btn-ghost:hover {
|
||||
border-color: color-mix(in oklab, var(--color-base-content) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
@utility menu-active {
|
||||
&,
|
||||
&:active {
|
||||
@@ -143,6 +187,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Reserve the document scrollbar gutter so the layout width stays constant when
|
||||
* a page-level scrollbar appears (e.g. once a container's logs load). Without
|
||||
* this, the width jump makes the percentage-based splitpanes recompute and the
|
||||
* sidebar visibly flicks. */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
@@ -150,6 +202,13 @@ body {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Height of the mobile top nav (logo row + border). The container status bar
|
||||
* sticks right below it and the log content is padded by the same amount, so
|
||||
* they stay locked together. The safe-area inset is already handled by body. */
|
||||
:root {
|
||||
--mobile-nav-height: 57px;
|
||||
}
|
||||
|
||||
[class*="shadow-"] {
|
||||
@apply shadow-base-content/8;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { owners } = storeToRefs(k8sStore);
|
||||
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
|
||||
const ownerKey = computed(() => {
|
||||
try {
|
||||
return decodeURIComponent(String(route.params.name));
|
||||
} catch {
|
||||
return String(route.params.name);
|
||||
}
|
||||
});
|
||||
const owner = computed(() => owners.value.find((o) => o.key === ownerKey.value));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Ref, UnwrapNestedRefs } from "vue";
|
||||
import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import i18n from "@/modules/i18n";
|
||||
import { parseEventData } from "@/utils/events";
|
||||
import { Host } from "./hosts";
|
||||
|
||||
const { showToast, removeToast } = useToast();
|
||||
@@ -54,11 +55,9 @@ export const useContainerStore = defineStore("container", () => {
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("containers-changed", (e: Event) =>
|
||||
updateContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[]),
|
||||
);
|
||||
es.addEventListener("containers-changed", (e) => updateContainers(parseEventData<ContainerJson[]>(e)));
|
||||
es.addEventListener("container-stat", (e) => {
|
||||
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
|
||||
const stat = parseEventData<ContainerStat>(e);
|
||||
const container = allContainersById.value[stat.id] as unknown as UnwrapNestedRefs<Container>;
|
||||
if (container) {
|
||||
const { id, ...rest } = stat;
|
||||
@@ -66,7 +65,7 @@ export const useContainerStore = defineStore("container", () => {
|
||||
}
|
||||
});
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string; time: string };
|
||||
const event = parseEventData<{ actorId: string; name: string; time: string }>(e);
|
||||
const container = allContainersById.value[event.actorId];
|
||||
if (container) {
|
||||
switch (event.name) {
|
||||
@@ -88,7 +87,7 @@ export const useContainerStore = defineStore("container", () => {
|
||||
});
|
||||
|
||||
es.addEventListener("container-updated", (e) => {
|
||||
const container = JSON.parse((e as MessageEvent).data) as ContainerJson;
|
||||
const container = parseEventData<ContainerJson>(e);
|
||||
const existing = allContainersById.value[container.id];
|
||||
if (existing) {
|
||||
existing.name = container.name;
|
||||
@@ -103,12 +102,12 @@ export const useContainerStore = defineStore("container", () => {
|
||||
});
|
||||
|
||||
es.addEventListener("update-host", (e) => {
|
||||
const host = JSON.parse((e as MessageEvent).data) as Host;
|
||||
const host = parseEventData<Host>(e);
|
||||
updateHost(host);
|
||||
});
|
||||
|
||||
es.addEventListener("container-health", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; health: ContainerHealth };
|
||||
const event = parseEventData<{ actorId: string; health: ContainerHealth }>(e);
|
||||
const container = allContainersById.value[event.actorId];
|
||||
if (container) {
|
||||
container.health = event.health;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { Container } from "@/models/Container";
|
||||
import { getK8sOwnerRefs, groupK8sOwners, ownerMembershipLabel } from "./k8s";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function makeContainer(id: string, labels: Record<string, string>) {
|
||||
return new Container(
|
||||
id,
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
id,
|
||||
"command",
|
||||
"localhost",
|
||||
labels,
|
||||
"running",
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
describe("getK8sOwnerRefs", () => {
|
||||
test("parses indexed owner-chain labels", () => {
|
||||
const container = makeContainer("api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "2",
|
||||
"@k8s.owner.0.kind": "ReplicaSet",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api-6f88b977f4",
|
||||
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
|
||||
"@k8s.owner.1.kind": "Deployment",
|
||||
"@k8s.owner.1.namespace": "default",
|
||||
"@k8s.owner.1.name": "api",
|
||||
"@k8s.owner.1.key": "Deployment~default~api",
|
||||
});
|
||||
|
||||
expect(getK8sOwnerRefs(container)).toEqual([
|
||||
{
|
||||
key: "ReplicaSet~default~api-6f88b977f4",
|
||||
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
|
||||
kind: "ReplicaSet",
|
||||
name: "api-6f88b977f4",
|
||||
namespace: "default",
|
||||
},
|
||||
{
|
||||
key: "Deployment~default~api",
|
||||
label: ownerMembershipLabel("Deployment~default~api"),
|
||||
kind: "Deployment",
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to legacy immediate owner labels", () => {
|
||||
const container = makeContainer("api", {
|
||||
namespace: "default",
|
||||
"owner.kind": "ReplicaSet",
|
||||
"owner.name": "api-6f88b977f4",
|
||||
});
|
||||
|
||||
expect(getK8sOwnerRefs(container)).toEqual([
|
||||
{
|
||||
key: "ReplicaSet~default~api-6f88b977f4",
|
||||
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
|
||||
kind: "ReplicaSet",
|
||||
name: "api-6f88b977f4",
|
||||
namespace: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses kind for display and type for CRD identity", () => {
|
||||
const container = makeContainer("rollout-api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "1",
|
||||
"@k8s.owner.0.kind": "Rollout",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api",
|
||||
"@k8s.owner.0.key": "argoproj.io~v1alpha1~Rollout~default~api",
|
||||
});
|
||||
|
||||
expect(getK8sOwnerRefs(container)).toEqual([
|
||||
{
|
||||
key: "argoproj.io~v1alpha1~Rollout~default~api",
|
||||
label: ownerMembershipLabel("argoproj.io~v1alpha1~Rollout~default~api"),
|
||||
kind: "Rollout",
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupK8sOwners", () => {
|
||||
test("groups a container under every owner in its chain", () => {
|
||||
const container = makeContainer("api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "2",
|
||||
"@k8s.owner.0.kind": "ReplicaSet",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api-6f88b977f4",
|
||||
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
|
||||
"@k8s.owner.1.kind": "Deployment",
|
||||
"@k8s.owner.1.namespace": "default",
|
||||
"@k8s.owner.1.name": "api",
|
||||
"@k8s.owner.1.key": "Deployment~default~api",
|
||||
});
|
||||
|
||||
const owners = groupK8sOwners([container]);
|
||||
|
||||
expect(owners.map((owner) => owner.key).sort()).toEqual([
|
||||
"Deployment~default~api",
|
||||
"ReplicaSet~default~api-6f88b977f4",
|
||||
]);
|
||||
expect(owners.every((owner) => owner.containers.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps same-name owners in different namespaces separate", () => {
|
||||
const owners = groupK8sOwners([
|
||||
makeContainer("default-api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "1",
|
||||
"@k8s.owner.0.kind": "Deployment",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api",
|
||||
"@k8s.owner.0.key": "Deployment~default~api",
|
||||
}),
|
||||
makeContainer("prod-api", {
|
||||
namespace: "prod",
|
||||
"@k8s.owner.count": "1",
|
||||
"@k8s.owner.0.kind": "Deployment",
|
||||
"@k8s.owner.0.namespace": "prod",
|
||||
"@k8s.owner.0.name": "api",
|
||||
"@k8s.owner.0.key": "Deployment~prod~api",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(owners.map((owner) => owner.key).sort()).toEqual(["Deployment~default~api", "Deployment~prod~api"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,14 @@ import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
export type K8sOwnerRef = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export class K8sNamespace {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -22,6 +30,9 @@ export class K8sOwner {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly kind: string,
|
||||
public readonly namespaceName: string | undefined,
|
||||
public readonly key: string,
|
||||
public readonly label: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
|
||||
@@ -32,6 +43,54 @@ export class K8sOwner {
|
||||
}
|
||||
}
|
||||
|
||||
export function ownerMembershipLabel(key: string) {
|
||||
const bytes = new TextEncoder().encode(key);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return `@k8s.owner.key.${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export function getK8sOwnerRefs(container: Container): K8sOwnerRef[] {
|
||||
const count = Number(container.labels["@k8s.owner.count"] ?? 0);
|
||||
if (count > 0) {
|
||||
const owners: K8sOwnerRef[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prefix = `@k8s.owner.${i}.`;
|
||||
const kind = container.labels[`${prefix}kind`];
|
||||
const name = container.labels[`${prefix}name`];
|
||||
const namespace = container.labels[`${prefix}namespace`];
|
||||
if (!kind || !name) continue;
|
||||
const key = container.labels[`${prefix}key`] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||
owners.push({ key, label: ownerMembershipLabel(key), kind, name, namespace });
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
// "~" matches the backend owner-key delimiter: URL-safe and invalid in Kubernetes names.
|
||||
const kind = container.labels["owner.kind"];
|
||||
const name = container.labels["owner.name"];
|
||||
if (!kind || !name) return [];
|
||||
|
||||
const namespace = container.labels["namespace"];
|
||||
const key = container.labels["owner.key"] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||
return [{ key, label: ownerMembershipLabel(key), kind, name, namespace }];
|
||||
}
|
||||
|
||||
export function groupK8sOwners(containers: Container[]) {
|
||||
const ownerGroups: Record<string, { owner: K8sOwnerRef; containers: Container[] }> = {};
|
||||
for (const container of containers) {
|
||||
for (const owner of getK8sOwnerRefs(container)) {
|
||||
ownerGroups[owner.key] ||= { owner, containers: [] };
|
||||
ownerGroups[owner.key].containers.push(container);
|
||||
}
|
||||
}
|
||||
return Object.values(ownerGroups).map(({ owner, containers }) => {
|
||||
return new K8sOwner(owner.name, owner.kind, owner.namespace, owner.key, owner.label, containers);
|
||||
});
|
||||
}
|
||||
|
||||
export const useK8sStore = defineStore("k8s", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
@@ -50,24 +109,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
const newNamespaces: K8sNamespace[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of containers) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
}
|
||||
|
||||
const newOwners: K8sOwner[] = [];
|
||||
|
||||
for (const [key, containers] of Object.entries(ownerGroups)) {
|
||||
const [kind, name] = key.split(":");
|
||||
newOwners.push(new K8sOwner(name, kind, containers));
|
||||
}
|
||||
const newOwners = groupK8sOwners(containers);
|
||||
|
||||
if (newOwners.length === 0) continue;
|
||||
|
||||
@@ -75,7 +117,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
new K8sNamespace(
|
||||
name,
|
||||
containers,
|
||||
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
newOwners.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -83,33 +125,23 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
const containersWithoutNamespace: Container[] = [];
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
const namespace = container.labels["namespace"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
if (namespace) {
|
||||
// Skip containers that are already part of a namespace
|
||||
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
||||
if (hasNamespace) continue;
|
||||
}
|
||||
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
containersWithoutNamespace.push(container);
|
||||
}
|
||||
|
||||
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
||||
|
||||
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
|
||||
const [kind, name] = key.split(":");
|
||||
return new K8sOwner(name, kind, containers);
|
||||
});
|
||||
const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
|
||||
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.key.localeCompare(b.key));
|
||||
});
|
||||
|
||||
const customGroups = computed(() => {
|
||||
|
||||
@@ -20,9 +20,9 @@ import type {
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface TypesConfig {
|
||||
ParamParsers:
|
||||
| never
|
||||
_ParamParsers: {}
|
||||
RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
|
||||
_RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,102 +168,137 @@ declare module 'vue-router/auto-routes' {
|
||||
| '/'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/[...all].vue': {
|
||||
routes:
|
||||
| '/[...all]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'all'
|
||||
}
|
||||
'assets/pages/cloud/search.vue': {
|
||||
routes:
|
||||
| '/cloud/search'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/container/[id].vue': {
|
||||
routes:
|
||||
| '/container/[id]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'id'
|
||||
}
|
||||
'assets/pages/container/[id].time.[datetime].vue': {
|
||||
routes:
|
||||
| '/container/[id].time.[datetime]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'datetime'
|
||||
| 'id'
|
||||
}
|
||||
'assets/pages/group/[name].vue': {
|
||||
routes:
|
||||
| '/group/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/host/[id].vue': {
|
||||
routes:
|
||||
| '/host/[id]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'id'
|
||||
}
|
||||
'assets/pages/host-group/[name].vue': {
|
||||
routes:
|
||||
| '/host-group/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/login.vue': {
|
||||
routes:
|
||||
| '/login'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/merged/[ids].vue': {
|
||||
routes:
|
||||
| '/merged/[ids]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'ids'
|
||||
}
|
||||
'assets/pages/namespace/[name].vue': {
|
||||
routes:
|
||||
| '/namespace/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/notifications.vue': {
|
||||
routes:
|
||||
| '/notifications'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/owner/[name].vue': {
|
||||
routes:
|
||||
| '/owner/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/service/[name].vue': {
|
||||
routes:
|
||||
| '/service/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/settings.vue': {
|
||||
routes:
|
||||
| '/settings'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/show.vue': {
|
||||
routes:
|
||||
| '/show'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/stack/[name].vue': {
|
||||
routes:
|
||||
| '/stack/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Parses the JSON payload of an SSE `MessageEvent`. EventSource listeners
|
||||
* receive a `MessageEvent` whose `data` is an untyped string, so the parsed
|
||||
* shape is provided via the type parameter at each call site.
|
||||
*/
|
||||
export function parseEventData<T>(event: Event): T {
|
||||
return JSON.parse((event as MessageEvent).data) as T;
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { formatDuration, toRelativeTime } from "./date";
|
||||
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
||||
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
||||
export { formatBytes, stripVersion, hashCode } from "./format";
|
||||
export { colorize } from "./ansi";
|
||||
|
||||
@@ -2,7 +2,7 @@ export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
x-dozzle-healthcheck: &dozzle-healthcheck
|
||||
test: ["CMD", "/dozzle", "healthcheck"]
|
||||
interval: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
start_interval: 5s
|
||||
|
||||
services:
|
||||
custom_base:
|
||||
container_name: custom_base
|
||||
@@ -10,6 +17,7 @@ services:
|
||||
- DOZZLE_HOSTNAME=custom name
|
||||
ports:
|
||||
- 8080:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
simple-auth:
|
||||
@@ -20,6 +28,7 @@ services:
|
||||
environment:
|
||||
- DOZZLE_AUTH_PROVIDER=simple
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
dozzle:
|
||||
@@ -33,8 +42,33 @@ services:
|
||||
- DOZZLE_LEVEL=debug
|
||||
ports:
|
||||
- 7070:8080
|
||||
# Deliberately no healthcheck: this container is the subject of the visual
|
||||
# snapshots, and a healthcheck makes a health badge appear in the sidebar
|
||||
# (HostMenu.vue), which shifts the reference images.
|
||||
build:
|
||||
context: .
|
||||
# Dedicated instance + noisy container so log streaming can be asserted without
|
||||
# perturbing the `name=dozzle` filter the other instances rely on.
|
||||
logs-viewer:
|
||||
container_name: logs-viewer
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DOZZLE_FILTER=name=logspam
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
- DOZZLE_HOSTNAME=logs
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
- logspam
|
||||
logspam:
|
||||
container_name: logspam
|
||||
image: alpine:3
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
|
||||
remote:
|
||||
container_name: remote
|
||||
environment:
|
||||
@@ -43,6 +77,7 @@ services:
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
ports:
|
||||
- 5050:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
@@ -56,6 +91,7 @@ services:
|
||||
- DOZZLE_LEVEL=debug
|
||||
ports:
|
||||
- 8082:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
@@ -85,6 +121,9 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- CONTAINERS=1
|
||||
# Dozzle calls /info on connect; without it the host never initializes and
|
||||
# lists zero containers.
|
||||
- INFO=1
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
||||
interval: 5s
|
||||
@@ -96,7 +135,7 @@ services:
|
||||
|
||||
playwright:
|
||||
container_name: playwright
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-jammy
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
@@ -105,6 +144,15 @@ services:
|
||||
- PWTEST_SKIP_TEST_OUTPUT=1
|
||||
- CI=1
|
||||
depends_on:
|
||||
- dozzle
|
||||
- custom_base
|
||||
- remote
|
||||
dozzle:
|
||||
condition: service_started
|
||||
custom_base:
|
||||
condition: service_healthy
|
||||
remote:
|
||||
condition: service_healthy
|
||||
simple-auth:
|
||||
condition: service_healthy
|
||||
dozzle-with-agent:
|
||||
condition: service_healthy
|
||||
logs-viewer:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
declare const data: { stars: number; pulls: number };
|
||||
declare const data: {
|
||||
stars: number;
|
||||
pulls: number;
|
||||
contributors: number;
|
||||
release: { version: string; date: string } | null;
|
||||
};
|
||||
export { data };
|
||||
|
||||
// The contributors endpoint has no total count, so ask for one per page and read
|
||||
// the last page number out of the Link header.
|
||||
async function fetchContributorCount() {
|
||||
const res = await fetch("https://api.github.com/repos/amir20/dozzle/contributors?per_page=1&anon=false");
|
||||
const match = res.headers.get("link")?.match(/[?&]page=(\d+)>; rel="last"/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease() {
|
||||
const res = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
|
||||
const json = await res.json();
|
||||
if (!json.tag_name) return null;
|
||||
return { version: json.tag_name, date: json.published_at };
|
||||
}
|
||||
|
||||
export default {
|
||||
async load() {
|
||||
const urls = [
|
||||
@@ -8,13 +28,18 @@ export default {
|
||||
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
|
||||
];
|
||||
|
||||
const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json())));
|
||||
const [repo, hub, contributors, release] = await Promise.all([
|
||||
...urls.map((url) => fetch(url).then((res) => res.json())),
|
||||
// Never fail the build over a rate-limited or flaky API call.
|
||||
fetchContributorCount().catch(() => 0),
|
||||
fetchLatestRelease().catch(() => null),
|
||||
]);
|
||||
|
||||
const data = {
|
||||
stars: responses[0].stargazers_count,
|
||||
pulls: responses[1].pull_count,
|
||||
return {
|
||||
stars: repo.stargazers_count,
|
||||
pulls: hub.pull_count,
|
||||
contributors,
|
||||
release,
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,14 @@ export default defineConfig({
|
||||
cleanUrls: true,
|
||||
|
||||
head: [
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:site_name", content: "Dozzle" }],
|
||||
["meta", { property: "og:url", content: "https://dozzle.dev/" }],
|
||||
["meta", { property: "og:image", content: "https://dozzle.dev/og-image.png" }],
|
||||
["meta", { property: "og:image:width", content: "1200" }],
|
||||
["meta", { property: "og:image:height", content: "630" }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:image", content: "https://dozzle.dev/og-image.png" }],
|
||||
[
|
||||
"script",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
import { withBase } from "vitepress";
|
||||
import InstallCommand from "./InstallCommand.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||
<div
|
||||
class="flex flex-col items-center rounded-2xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) px-6 py-16"
|
||||
>
|
||||
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
|
||||
Start watching your containers
|
||||
</h2>
|
||||
<p class="mt-3! mb-0! max-w-xl text-center text-(--vp-c-text-2)">
|
||||
One command, no agent to install, no account to create.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 w-full">
|
||||
<InstallCommand :hint="false" dense />
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<a class="cta-button cta-brand" :href="withBase('/guide/getting-started')">Get Started</a>
|
||||
<a class="cta-button cta-alt" href="https://github.com/amir20/dozzle">
|
||||
<Icon icon="simple-icons:github" class="size-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-8! mb-0! text-sm text-(--vp-c-text-2)">MIT licensed and free forever.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Mirror VitePress's own button tokens so these match the hero buttons in both themes. */
|
||||
.cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
text-decoration: none !important;
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
border-color 0.25s,
|
||||
color 0.25s;
|
||||
}
|
||||
|
||||
.cta-brand {
|
||||
border-color: var(--vp-button-brand-border);
|
||||
background-color: var(--vp-button-brand-bg);
|
||||
color: var(--vp-button-brand-text);
|
||||
}
|
||||
|
||||
.cta-brand:hover {
|
||||
border-color: var(--vp-button-brand-hover-border);
|
||||
background-color: var(--vp-button-brand-hover-bg);
|
||||
color: var(--vp-button-brand-hover-text);
|
||||
}
|
||||
|
||||
.cta-alt {
|
||||
border-color: var(--vp-button-alt-border);
|
||||
background-color: var(--vp-button-alt-bg);
|
||||
color: var(--vp-button-alt-text);
|
||||
}
|
||||
|
||||
.cta-alt:hover {
|
||||
border-color: var(--vp-button-alt-hover-border);
|
||||
background-color: var(--vp-button-alt-hover-bg);
|
||||
color: var(--vp-button-alt-hover-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { data } from "../../activity.data";
|
||||
|
||||
const cues = [
|
||||
{ icon: "mdi:scale-balance", label: "MIT licensed" },
|
||||
{ icon: "mdi:package-variant-closed", label: "Single binary, no database" },
|
||||
{ icon: "mdi:server-security", label: "Self-hosted, your logs stay on your network" },
|
||||
];
|
||||
|
||||
const released = computed(() => {
|
||||
if (!data.release) return null;
|
||||
const days = Math.floor((Date.now() - new Date(data.release.date).getTime()) / 86_400_000);
|
||||
if (days <= 0) return "released today";
|
||||
if (days === 1) return "released yesterday";
|
||||
if (days < 30) return `released ${days} days ago`;
|
||||
const months = Math.round(days / 30);
|
||||
return `released ${months} month${months === 1 ? "" : "s"} ago`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex flex-col gap-2 text-sm text-(--vp-c-text-2)">
|
||||
<ul class="m-0! flex list-none flex-wrap gap-x-5 gap-y-2 p-0!">
|
||||
<li v-for="cue in cues" :key="cue.label" class="flex items-center gap-1.5">
|
||||
<Icon :icon="cue.icon" class="size-4 opacity-70" />
|
||||
{{ cue.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
v-if="data.release"
|
||||
class="flex w-fit items-center gap-1.5 text-(--vp-c-text-3) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
:href="`https://github.com/amir20/dozzle/releases/tag/${data.release.version}`"
|
||||
>
|
||||
<span class="relative flex size-2">
|
||||
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60"></span>
|
||||
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
|
||||
</span>
|
||||
{{ data.release.version }} {{ released }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMutationObserver } from "@vueuse/core";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||
import { useData } from "vitepress";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const isDark = ref(false);
|
||||
onMounted(() => {
|
||||
isDark.value = document.documentElement.classList.contains("dark");
|
||||
useMutationObserver(
|
||||
document.documentElement,
|
||||
(mutations) => {
|
||||
isDark.value = document.documentElement.classList.contains("dark");
|
||||
},
|
||||
{
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
},
|
||||
);
|
||||
});
|
||||
import darkPoster from "../media/poster-dark.webp";
|
||||
import lightPoster from "../media/poster-light.webp";
|
||||
import darkVideo from "../media/dozzle-dark.mp4";
|
||||
import lightVideo from "../media/dozzle-light.mp4";
|
||||
|
||||
const { isDark } = useData();
|
||||
const reducedMotion = usePreferredReducedMotion();
|
||||
|
||||
// The posters are rendered on the server and toggled with CSS so the first paint
|
||||
// is already the right theme. The video only mounts on the client, which keeps
|
||||
// markup identical between SSR and hydration and avoids downloading both files.
|
||||
const mounted = ref(false);
|
||||
onMounted(() => (mounted.value = true));
|
||||
|
||||
const playVideo = computed(() => mounted.value && reducedMotion.value !== "reduce");
|
||||
const video = computed(() => (isDark.value ? darkVideo : lightVideo));
|
||||
const poster = computed(() => (isDark.value ? darkPoster : lightPoster));
|
||||
|
||||
const alt = "The Dozzle interface streaming container logs in real time";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] text-[red] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
class="relative overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
>
|
||||
<video muted loop autoplay playsinline poster="../media/poster-dark.png" v-if="isDark">
|
||||
<source src="../media/dozzle-dark.mp4" type="video/mp4" />
|
||||
<img src="../media/poster-dark.png" alt="" />
|
||||
</video>
|
||||
<video muted loop autoplay playsinline poster="../media/poster-light.png" v-else>
|
||||
<source src="../media/dozzle-light.mp4" type="video/mp4" />
|
||||
<img src="../media/poster-light.png" alt="" />
|
||||
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
|
||||
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
|
||||
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
|
||||
<video
|
||||
v-if="playVideo"
|
||||
:key="video"
|
||||
class="absolute inset-0 size-full"
|
||||
muted
|
||||
loop
|
||||
autoplay
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-hidden="true"
|
||||
:poster="poster"
|
||||
>
|
||||
<source :src="video" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
const { hint = true, dense = false } = defineProps<{
|
||||
hint?: boolean;
|
||||
dense?: boolean;
|
||||
}>();
|
||||
|
||||
const command =
|
||||
"docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest";
|
||||
|
||||
const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-[1152px] flex-col items-center gap-3 px-6" :class="dense ? 'mb-0' : 'mb-14'">
|
||||
<div
|
||||
class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4"
|
||||
>
|
||||
<span class="shrink-0 font-mono text-xs leading-6 text-(--vp-c-text-3) select-none md:text-sm">$</span>
|
||||
<code
|
||||
class="min-w-0 flex-1 bg-transparent! p-0! font-mono text-xs leading-6 break-words whitespace-pre-wrap text-(--vp-c-text-1) md:text-sm"
|
||||
>{{ command }}</code
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 cursor-pointer rounded-md border border-solid border-(--vp-c-divider) bg-(--vp-c-bg) px-3 py-1.5 text-xs font-medium text-(--vp-c-text-2) transition-colors hover:border-(--vp-c-brand) hover:text-(--vp-c-brand)"
|
||||
:aria-label="copied ? 'Command copied' : 'Copy command to clipboard'"
|
||||
@click="copy()"
|
||||
>
|
||||
{{ copied ? "Copied" : "Copy" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="hint" class="m-0! text-sm text-(--vp-c-text-2)">
|
||||
Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the
|
||||
<a :href="withBase('/guide/getting-started')">install guide</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { withBase } from "vitepress";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-16 mb-4 flex items-center justify-center px-6">
|
||||
<a
|
||||
class="flex items-center gap-2 text-sm text-(--vp-c-text-2) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
href="https://www.docker.com/blog/docker-sponsored-open-source-program/"
|
||||
>
|
||||
<img :src="withBase('/icons/docker-icon.svg')" width="20" height="20" alt="" />
|
||||
Open source and part of Docker's sponsored OSS program
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,53 @@
|
||||
<template>
|
||||
<div class="stats stats-horizontal mt-10">
|
||||
<div class="stat w-40 border-0!">
|
||||
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div>
|
||||
<div class="stat-value">
|
||||
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="(value) => (value / 1e3).toFixed(1) + 'K'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div>
|
||||
<div class="stat-value">
|
||||
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="(value) => (value / 1e6).toFixed(0) + 'M'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
|
||||
<a
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
:href="stat.href"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
|
||||
<Icon :icon="stat.icon" class="size-4 opacity-70" />
|
||||
{{ stat.label }}
|
||||
</span>
|
||||
<span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
|
||||
<Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { data } from "../../activity.data";
|
||||
import Counter from "./Counter.vue";
|
||||
|
||||
const stats = computed(() =>
|
||||
[
|
||||
{
|
||||
icon: "simple-icons:github",
|
||||
label: "Github Stars",
|
||||
value: data.stars,
|
||||
href: "https://github.com/amir20/dozzle/stargazers",
|
||||
},
|
||||
{
|
||||
icon: "simple-icons:docker",
|
||||
label: "Docker Pulls",
|
||||
value: data.pulls,
|
||||
href: "https://hub.docker.com/r/amir20/dozzle",
|
||||
},
|
||||
{
|
||||
icon: "mdi:account-group-outline",
|
||||
label: "Contributors",
|
||||
value: data.contributors,
|
||||
href: "https://github.com/amir20/dozzle/graphs/contributors",
|
||||
},
|
||||
].filter((stat) => stat.value),
|
||||
);
|
||||
|
||||
const compact = (value: number) => {
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B";
|
||||
if (value >= 1e6) return Math.round(value / 1e6) + "M";
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(1) + "K";
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
// Real quotes only. Drop in verbatim text with a link back to the source
|
||||
// (GitHub issue, HN thread, r/selfhosted post) so anyone can check it.
|
||||
// The section hides itself while this is empty.
|
||||
type Testimonial = { quote: string; author: string; source: string; href: string };
|
||||
|
||||
const testimonials: Testimonial[] = [];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="testimonials.length" class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">What people say</h2>
|
||||
|
||||
<div class="mt-10 grid gap-4 md:grid-cols-3">
|
||||
<figure
|
||||
v-for="testimonial in testimonials"
|
||||
:key="testimonial.href"
|
||||
class="m-0! flex flex-col rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6"
|
||||
>
|
||||
<Icon icon="mdi:format-quote-close" class="size-6 text-(--vp-c-text-3) opacity-60" />
|
||||
<blockquote class="mt-3! mb-0! border-0! p-0! text-(--vp-c-text-1)">{{ testimonial.quote }}</blockquote>
|
||||
<figcaption class="mt-4! text-sm text-(--vp-c-text-2)">
|
||||
{{ testimonial.author }} on
|
||||
<a :href="testimonial.href">{{ testimonial.source }}</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
const rows = [
|
||||
{
|
||||
cli: "One container at a time, per host.",
|
||||
dozzle: "Follow many containers side by side, merged across every host you connect.",
|
||||
},
|
||||
{
|
||||
cli: "Pipe to grep and start over when you get it wrong.",
|
||||
dozzle: "Search, filter by level, and jump to a timestamp while the logs keep streaming.",
|
||||
},
|
||||
{
|
||||
cli: "JSON and stack traces arrive as a wall of text.",
|
||||
dozzle: "Structured logs get parsed into fields, multi-line traces stay grouped.",
|
||||
},
|
||||
{
|
||||
cli: "Separate docker stats window for CPU and memory.",
|
||||
dozzle: "Live CPU, memory, and network charts on the same screen as the logs.",
|
||||
},
|
||||
{
|
||||
cli: "SSH into each machine to look around.",
|
||||
dozzle: "One browser tab over Docker, Swarm, and Kubernetes, with a shell when you need it.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
|
||||
Why not just <code class="bg-transparent! p-0! font-mono">docker logs</code>?
|
||||
</h2>
|
||||
<p class="mx-auto mt-3! mb-0! max-w-2xl text-center text-(--vp-c-text-2)">
|
||||
It works fine until you are tailing three containers on two hosts at 2am.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-text-3) uppercase">
|
||||
<Icon icon="mdi:console" class="size-4" />
|
||||
docker logs
|
||||
</div>
|
||||
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
|
||||
<li v-for="row in rows" :key="row.cli" class="flex gap-2.5 text-(--vp-c-text-2)">
|
||||
<Icon icon="mdi:close-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-text-3)" />
|
||||
<span>{{ row.cli }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-solid border-(--vp-c-brand-dimm) bg-(--vp-c-brand-dimm) p-6">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-brand) uppercase">
|
||||
<Icon icon="mdi:television-play" class="size-4" />
|
||||
Dozzle
|
||||
</div>
|
||||
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
|
||||
<li v-for="row in rows" :key="row.dozzle" class="flex gap-2.5 text-(--vp-c-text-1)">
|
||||
<Icon icon="mdi:check-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-brand)" />
|
||||
<span>{{ row.dozzle }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,7 +8,13 @@ import "./style.css";
|
||||
import HeroVideo from "./components/HeroVideo.vue";
|
||||
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
||||
import Stats from "./components/Stats.vue";
|
||||
import HeroTrust from "./components/HeroTrust.vue";
|
||||
import Supported from "./components/Supported.vue";
|
||||
import InstallCommand from "./components/InstallCommand.vue";
|
||||
import WhyDozzle from "./components/WhyDozzle.vue";
|
||||
import Testimonials from "./components/Testimonials.vue";
|
||||
import FinalCta from "./components/FinalCta.vue";
|
||||
import SponsoredBy from "./components/SponsoredBy.vue";
|
||||
|
||||
export default {
|
||||
...DefaultTheme,
|
||||
@@ -16,8 +22,9 @@ export default {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
"home-hero-image": () => h(HeroVideo),
|
||||
"sidebar-nav-after": () => h(BuyMeCoffee),
|
||||
"home-hero-actions-after": () => h(Stats),
|
||||
"home-hero-after": () => h(Supported),
|
||||
"home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
|
||||
"home-hero-after": () => [h(InstallCommand), h(Supported)],
|
||||
"home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
|
||||
});
|
||||
},
|
||||
enhanceApp(ctx) {
|
||||
|
||||
|
Before Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -154,8 +154,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.VPHomeHero .text {
|
||||
@apply text-3xl font-normal md:text-5xl;
|
||||
}
|
||||
|
||||
.VPHomeHero .tagline {
|
||||
@apply font-sans text-4xl font-light;
|
||||
@apply font-sans text-lg font-light md:text-2xl;
|
||||
color: var(--tag-line-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,16 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
||||
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
|
||||
FinalCta: typeof import('./.vitepress/theme/components/FinalCta.vue')['default']
|
||||
HeroTrust: typeof import('./.vitepress/theme/components/HeroTrust.vue')['default']
|
||||
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
|
||||
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
|
||||
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
|
||||
Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default']
|
||||
Testimonials: typeof import('./.vitepress/theme/components/Testimonials.vue')['default']
|
||||
WhyDozzle: typeof import('./.vitepress/theme/components/WhyDozzle.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,11 +176,11 @@ Metric alerts fire when a container's CPU or memory usage crosses a threshold. T
|
||||
|
||||
Available properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------- | ------ | ----------------------------------------------- |
|
||||
| `cpu` | number | CPU usage percentage (0–100, per core-adjusted) |
|
||||
| `memory` | number | Memory usage percentage (0–100) |
|
||||
| `memoryUsage` | number | Memory usage in bytes |
|
||||
| Property | Type | Description |
|
||||
| ------------- | ------ | --------------------------------------------- |
|
||||
| `cpu` | number | CPU usage percentage (0–100), matching the UI |
|
||||
| `memory` | number | Memory usage percentage (0–100) |
|
||||
| `memoryUsage` | number | Memory usage in bytes |
|
||||
|
||||
### Cooldown & Sample Window
|
||||
|
||||
@@ -227,6 +227,8 @@ Available properties:
|
||||
|
||||
Common Docker event names include `start`, `stop`, `die`, `kill`, `oom`, `restart`, `destroy`, and `health_status`.
|
||||
|
||||
For `health_status` events, Dozzle exposes the current state as `attributes["healthStatus"]` (`healthy` or `unhealthy`).
|
||||
|
||||
### Event Examples
|
||||
|
||||
**Alert when any production container dies:**
|
||||
@@ -247,14 +249,16 @@ Event: name == "oom"
|
||||
|
||||
```
|
||||
Container: true
|
||||
Event: name == "health_status" && attributes["health_status"] == "unhealthy"
|
||||
Event: name == "health_status" && attributes["healthStatus"] == "unhealthy"
|
||||
```
|
||||
|
||||
**Alert on unexpected exits (non-zero exit code):**
|
||||
**Alert on unexpected exits (ignoring clean and graceful shutdowns):**
|
||||
|
||||
Exit codes 0 (success), 130 (SIGINT), 143 (SIGTERM), and 137 (SIGKILL) fire on `docker stop`, Ctrl+C, and update cycles, so they are excluded to avoid noise. Genuine error exits (1, 2, 125, ...) still alert.
|
||||
|
||||
```
|
||||
Container: name contains "worker"
|
||||
Event: name == "die" && attributes["exitCode"] != "0"
|
||||
Event: name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])
|
||||
```
|
||||
|
||||
## <Icon icon="mdi:cog-outline" inline /> Managing Alerts
|
||||
|
||||
@@ -16,7 +16,7 @@ Dozzle has access to `docker.sock`, which — unless restricted — is equivalen
|
||||
- **Keep [actions](/guide/actions) and [shell access](/guide/shell) disabled** unless you need them. They allow starting, stopping, recreating, and executing arbitrary commands inside containers.
|
||||
- **Restrict users with [roles](#setting-specific-roles-for-users) and [filters](#setting-specific-filters-for-users)** in multi-user mode. Without explicit roles, a user can see every container the Dozzle instance can.
|
||||
- **Run TLS at the reverse proxy**. See [Reverse Proxy & Base Path](/guide/changing-base) for Nginx / Traefik / Caddy examples.
|
||||
- **Avoid mounting `docker.sock` read-write** if you don't need actions. A read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) still exposes most of the API but blocks container create/delete/update.
|
||||
- **Restrict `docker.sock` access with a proxy** if you don't need actions. Note that a read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) does _not_ limit the API: the `:ro` flag only marks the socket file read-only on disk, while API calls still pass through the socket normally, so create/delete/update remain possible. To actually restrict operations, put a socket proxy like [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy) in front of the daemon.
|
||||
|
||||
## <Icon icon="mdi:account-cog-outline" inline /> File-Based User Management
|
||||
|
||||
@@ -189,6 +189,14 @@ docker run -it --rm amir20/dozzle generate admin --password password --email tes
|
||||
|
||||
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. The `--user-roles` flag is a comma-separated list of roles.
|
||||
|
||||
If you omit `--password`, Dozzle prompts for it on stdin so the password never lands in your shell history. This requires an interactive terminal, so keep the `-it` flags:
|
||||
|
||||
```sh
|
||||
docker run -it --rm amir20/dozzle generate admin --email test@email.net --name "John Doe" > users.yml
|
||||
```
|
||||
|
||||
The prompt is written to stderr, so redirecting stdout to `users.yml` still works. You can also pipe the password in, for example `echo "$PASSWORD" | docker run -i --rm amir20/dozzle generate admin > users.yml`.
|
||||
|
||||
## <Icon icon="mdi:swap-horizontal" inline /> Forward Proxy
|
||||
|
||||
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
|
||||
|
||||
@@ -158,6 +158,12 @@ If you need a more opinionated experience with richer provider integrations (e.g
|
||||
|
||||
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
|
||||
|
||||
## Why does Dozzle keep dockerd and containerd at slightly elevated CPU when no browser is connected?
|
||||
|
||||
Dozzle keeps container stats streaming for up to 6 hours (2 hours on Kubernetes) after the last browser disconnects, then shuts the stats collector down on its own. This is intentional. Stats are streamed continuously so that when you reopen the UI you see historical CPU and memory history instead of a blank chart. If streaming stopped the moment you closed the tab, there would be no history to show.
|
||||
|
||||
The cost is a small, steady amount of CPU in dockerd and containerd, since Docker's stats API is polling based. Restarting the Dozzle container resets the timer immediately, which is why a restart drops the host back to idle. This is not configurable by design. A small timeout would break other functionality that assumes stats are still streaming, so lowering it defeats the purpose of the stats history.
|
||||
|
||||
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
|
||||
|
||||
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
|
||||
|
||||
@@ -26,6 +26,12 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
|
||||
@@ -39,6 +39,7 @@ All tools are **read-only** and do not modify containers.
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `list_containers` | List all containers across all hosts. Supports optional `state` filter. |
|
||||
| `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. |
|
||||
| `search_container_logs`| Search container logs for a keyword or phrase. Returns only matching entries. |
|
||||
| `list_hosts` | List all connected Docker hosts. |
|
||||
| `get_container_stats` | Get CPU and memory usage history for a container. |
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
title: Home
|
||||
titleTemplate: Real-time Docker Log Viewer
|
||||
description: Dozzle is a lightweight, open-source log viewer for Docker, Swarm, and Kubernetes. Stream logs, watch live stats, and debug containers from your browser.
|
||||
|
||||
hero:
|
||||
name: "Dozzle"
|
||||
text: "See what your containers are doing"
|
||||
tagline: Real-time Docker logs, stats, and debugging — in your browser.
|
||||
actions:
|
||||
- theme: brand
|
||||
@@ -22,12 +24,16 @@ features:
|
||||
src: /icons/document.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/what-is-dozzle#advanced-log-handling
|
||||
linkText: Learn More
|
||||
- title: Live Stats & Metrics
|
||||
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
|
||||
icon:
|
||||
src: /icons/chart-line-data.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/what-is-dozzle#real-time-monitoring
|
||||
linkText: Learn More
|
||||
- title: SQL Log Analysis
|
||||
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
|
||||
icon:
|
||||
@@ -52,12 +58,6 @@ features:
|
||||
height: 36
|
||||
link: /guide/remote-hosts
|
||||
linkText: Learn More
|
||||
- title: Self-hosted & Private
|
||||
details: Runs in your own infrastructure. Your logs never leave your network.
|
||||
icon:
|
||||
src: /icons/locked.svg
|
||||
width: 36
|
||||
height: 36
|
||||
- title: Shell & Exec Access
|
||||
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
|
||||
icon:
|
||||
@@ -66,12 +66,28 @@ features:
|
||||
height: 36
|
||||
link: /guide/shell
|
||||
linkText: Learn More
|
||||
- title: Officially Sponsored by Docker
|
||||
details: Open source and part of Docker's sponsored OSS program.
|
||||
- title: dtop in Your Terminal
|
||||
details: A command-line companion that gives you a live view of your containers, then jumps straight into Dozzle.
|
||||
icon:
|
||||
src: /icons/docker-icon.svg
|
||||
src: /icons/terminal-command.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: https://hub.docker.com/r/amir20/dozzle
|
||||
linkText: Docker Hub
|
||||
link: /guide/dtop
|
||||
linkText: Learn More
|
||||
- title: MCP for AI Assistants
|
||||
details: Expose containers, logs, and stats over the Model Context Protocol so your coding agent can debug alongside you.
|
||||
icon:
|
||||
src: /icons/ai.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/mcp
|
||||
linkText: Learn More
|
||||
- title: Self-hosted & Private
|
||||
details: Runs in your own infrastructure with simple or forward-proxy auth. Your logs never leave your network.
|
||||
icon:
|
||||
src: /icons/locked.svg
|
||||
width: 36
|
||||
height: 36
|
||||
link: /guide/authentication
|
||||
linkText: Learn More
|
||||
---
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dozzle": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/playfair-display": "^5.2.8"
|
||||
"@fontsource-variable/playfair-display": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#646cff" d="m14 3l2.4 6.6L23 12l-6.6 2.4L14 21l-2.4-6.6L5 12l6.6-2.4z"/><path fill="#646cff" d="m24 18l1.4 3.6L29 23l-3.6 1.4L24 28l-1.4-3.6L19 23l3.6-1.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#646cff" d="M26 4.01H6a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-20a2 2 0 0 0-2-2m0 2v4H6v-4Zm-20 20v-14h20v14Z"/><path fill="#646cff" d="M9 18h3v6H9zm5.5-3h3v9h-3zM20 20h3v4h-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 126 KiB |
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
Components({
|
||||
dirs: [path.resolve(__dirname, ".vitepress/theme/components")],
|
||||
dirs: [path.resolve(import.meta.dirname, ".vitepress/theme/components")],
|
||||
extensions: ["vue", "md"],
|
||||
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
|
||||
dts: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://dozzle-with-agent:8080/");
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://remote:8080/");
|
||||
});
|
||||
|
||||
test("has right title", async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||
});
|
||||
|
||||
test("select running container", async ({ page }) => {
|
||||
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
|
||||
await expect(page).toHaveURL(/\/container/);
|
||||
await expect(page.getByText("Accepting connections")).toBeVisible();
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const origin = "http://custom_base:8080";
|
||||
const base = "/foobarbase";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://custom_base:8080/foobarbase");
|
||||
await page.goto(origin + base);
|
||||
});
|
||||
|
||||
test("has right title", async ({ page }) => {
|
||||
@@ -11,3 +14,35 @@ test("has right title", async ({ page }) => {
|
||||
test("url should have custom base", async ({ page }) => {
|
||||
await expect(page).toHaveURL(/foobarbase/);
|
||||
});
|
||||
|
||||
test("static assets resolve under the custom base", async ({ page }) => {
|
||||
const requested: string[] = [];
|
||||
const broken: string[] = [];
|
||||
|
||||
const isAsset = (url: string) => new URL(url).pathname.includes("/assets/");
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (isAsset(request.url())) requested.push(new URL(request.url()).pathname);
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (isAsset(response.url()) && response.status() >= 400) {
|
||||
broken.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||
// fonts are only fetched when a glyph needs them, so force the @font-face src to resolve.
|
||||
// swallow the rejection on a bad url so the assertions below report which path was wrong
|
||||
await page.evaluate(() =>
|
||||
document.fonts.load('400 1rem "JetBrains Mono"').then(
|
||||
() => {},
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
// the css lives at <base>/assets/, so a relative url() in it must resolve back under <base>
|
||||
expect(requested.some((path) => path.endsWith(".woff2"))).toBe(true);
|
||||
expect(requested.filter((path) => !path.startsWith(`${base}/assets/`))).toEqual([]);
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Targets a dedicated instance filtered to the `logspam` container, which prints a
|
||||
// numbered line every second. That makes "logs are actually streaming" assertable
|
||||
// without depending on incidental output from Dozzle itself.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://logs-viewer:8080/");
|
||||
await page.getByTestId("side-menu").getByRole("link", { name: "logspam" }).click();
|
||||
await expect(page).toHaveURL(/\/container\//);
|
||||
});
|
||||
|
||||
test("renders historical logs on load", async ({ page }) => {
|
||||
await expect(page.locator("ul[data-logs] > li").first()).toBeVisible();
|
||||
await expect(page.getByTestId("no-logs")).toBeHidden();
|
||||
await expect(page.getByText(/logspam line \d+/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("streams new logs over SSE without a reload", async ({ page }) => {
|
||||
const entries = page.locator("ul[data-logs] > li");
|
||||
await expect(entries.first()).toBeVisible();
|
||||
|
||||
const initial = await entries.count();
|
||||
// logspam emits ~1 line/sec, so a few new entries must arrive on the open stream.
|
||||
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
|
||||
});
|
||||
|
||||
test("keeps the newest log entry in view while streaming", async ({ page }) => {
|
||||
const entries = page.locator("ul[data-logs] > li");
|
||||
await expect(entries.first()).toBeVisible();
|
||||
|
||||
const initial = await entries.count();
|
||||
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
|
||||
await expect(entries.last()).toBeInViewport();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect, devices } from "@playwright/test";
|
||||
|
||||
test.use({ ...devices["Pixel 5"] });
|
||||
|
||||
test.describe("mobile stats", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://dozzle:8080/");
|
||||
});
|
||||
|
||||
test("shows CPU and memory stats for containers on the dashboard", async ({ page }) => {
|
||||
const row = page.locator("tr").filter({ has: page.getByTitle("dozzle") });
|
||||
await expect(row.getByText(/^\d+%$/)).toBeVisible();
|
||||
await expect(row.getByText(/^[\d.]+ ?[KMGT]?B$/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("locks the container stats header directly under the mobile nav, with no gap or overlap", async ({ page }) => {
|
||||
const row = page.locator("tr").filter({ has: page.getByTitle("dozzle") });
|
||||
await row.getByTitle("dozzle").click();
|
||||
await expect(page).toHaveURL(/\/container\//);
|
||||
|
||||
const nav = await page.getByTestId("navigation").boundingBox();
|
||||
const header = await page.getByTestId("scrollable-header").boundingBox();
|
||||
|
||||
expect(nav).not.toBeNull();
|
||||
expect(header).not.toBeNull();
|
||||
// The stats header must sit flush against the bottom of the fixed nav bar:
|
||||
// any gap exposes empty space, any overlap clips the CPU/MEM chips underneath the nav.
|
||||
expect(Math.abs(header!.y - (nav!.y + nav!.height))).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Covers DOZZLE_REMOTE_HOST (tcp:// through the socket proxy), a different code path
|
||||
// than DOZZLE_REMOTE_AGENT. See agent.spec.ts for that one.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://remote:8080/");
|
||||
});
|
||||
|
||||
test("has right title", async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||
});
|
||||
|
||||
test("shows the labeled remote host", async ({ page }) => {
|
||||
// The host column on the dashboard, rather than a bare getByText: the label also
|
||||
// appears in the sidebar and the merge link, and which of them exist depends on
|
||||
// whether containers have loaded yet.
|
||||
await expect(page.getByRole("cell", { name: "remote-host" }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("select running container", async ({ page }) => {
|
||||
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
|
||||
await expect(page).toHaveURL(/\/container/);
|
||||
await expect(page.getByText("Accepting connections")).toBeVisible();
|
||||
});
|
||||
@@ -1,9 +1,59 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("simple authentication", async ({ page }) => {
|
||||
await page.goto("http://simple-auth:8080/");
|
||||
const BASE = "http://simple-auth:8080";
|
||||
|
||||
async function login(page: import("@playwright/test").Page) {
|
||||
await page.locator('input[name="username"]').fill("admin");
|
||||
await page.locator('input[name="password"]').fill("password");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
}
|
||||
|
||||
test("simple authentication", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("settings")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated request redirects to the login page", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("preserves the requested url as redirectUrl", async ({ page }) => {
|
||||
await page.goto(BASE + "/settings");
|
||||
await expect(page).toHaveURL(/\/login\?redirectUrl=/);
|
||||
});
|
||||
|
||||
test("rejects a wrong password", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await page.locator('input[name="username"]').fill("admin");
|
||||
await page.locator('input[name="password"]').fill("not-the-password");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText("Username or password are not valid")).toBeVisible();
|
||||
await expect(page.getByTestId("settings")).toBeHidden();
|
||||
});
|
||||
|
||||
test("api rejects unauthenticated requests with 401", async ({ request }) => {
|
||||
// A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
|
||||
for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
|
||||
const response = await request.get(BASE + path, { maxRedirects: 0 });
|
||||
expect(response.status(), `${path} should be unauthorized`).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
test("logout clears the session", async ({ page }) => {
|
||||
await page.goto(BASE + "/");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("settings")).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-menu").locator("label").click();
|
||||
await page.getByRole("button", { name: "Logout" }).click();
|
||||
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
|
||||
// The cookie must be gone server-side, not just visually logged out.
|
||||
const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -13,6 +13,12 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
|
||||