mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 16:11:58 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cbf0ca9e4 | |||
| 846343a9da |
@@ -5,7 +5,7 @@ tmp_dir = "tmp"
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dozzle"
|
||||
cmd = "GOEXPERIMENT=jsonv2 go build -race -o ./tmp/dozzle ."
|
||||
cmd = "go build -race -o ./tmp/dozzle ."
|
||||
delay = 1000
|
||||
exclude_dir = [
|
||||
"assets",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(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
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
@@ -8,18 +8,16 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -31,12 +29,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.25.5
|
||||
go-version: 1.24.2
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
@@ -50,18 +48,16 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -82,7 +78,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -94,20 +90,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -125,7 +121,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@v6.18.0
|
||||
uses: docker/build-push-action@v6.16.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
@@ -141,13 +137,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -13,20 +13,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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -39,7 +39,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@v6.18.0
|
||||
uses: docker/build-push-action@v6.16.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -24,14 +24,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
- uses: pnpm/action-setup@v2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
pnpm docs:build
|
||||
touch docs/.vitepress/dist/.nojekyll
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
|
||||
@@ -15,6 +15,6 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v6
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+25
-27
@@ -11,18 +11,16 @@ jobs:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -33,14 +31,16 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -52,12 +52,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.5"
|
||||
go-version: "1.24.2"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
@@ -73,16 +73,16 @@ jobs:
|
||||
name: Go Staticcheck
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.5"
|
||||
go-version: "1.24.2"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
- name: Stactic checker
|
||||
uses: dominikh/staticcheck-action@v1.4.0
|
||||
uses: dominikh/staticcheck-action@v1.3.1
|
||||
with:
|
||||
install-go: false
|
||||
int-test:
|
||||
@@ -90,18 +90,16 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -120,7 +118,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -15,4 +15,3 @@ coverage.out
|
||||
*.pem
|
||||
*.csr
|
||||
tmp
|
||||
.claude
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
typed-router.d.ts
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
dist
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Comment Style
|
||||
|
||||
**Always use ultra-brief mode for all PR reviews and responses.**
|
||||
|
||||
Format:
|
||||
|
||||
- Critical issues only (bugs, security, blockers)
|
||||
- Brief bullet points, no lengthy explanations
|
||||
- Skip verbose sections (no "Strengths", "Summary", etc.)
|
||||
- Include file:line references when relevant
|
||||
- Maximum ~10-15 lines per response
|
||||
|
||||
## Project Overview
|
||||
|
||||
Dozzle is a lightweight, web-based Docker log viewer with real-time monitoring capabilities. It's a hybrid application with:
|
||||
|
||||
- **Backend**: Go (HTTP server, Docker API client, WebSocket streaming)
|
||||
- **Frontend**: Vue 3 (SPA with Vite, TypeScript)
|
||||
|
||||
The application supports multiple deployment modes: standalone server, Docker Swarm, and Kubernetes (k8s).
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Install Go tools (protobuf, air hot-reloader)
|
||||
make tools
|
||||
|
||||
# Generate certificates and protobuf files
|
||||
make generate
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Run full development environment (backend + frontend with hot reload)
|
||||
make dev
|
||||
|
||||
# Alternative: Run backend and frontend separately
|
||||
pnpm run watch:backend # Go backend with air (port 3100)
|
||||
pnpm run watch:frontend # Vite dev server (port 3100)
|
||||
|
||||
# Run in agent mode for development
|
||||
pnpm run agent:dev
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build frontend assets
|
||||
pnpm build
|
||||
# or
|
||||
make dist
|
||||
|
||||
# Build entire application (includes frontend build)
|
||||
make build
|
||||
|
||||
# Build Docker image
|
||||
make docker
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run Go tests
|
||||
make test
|
||||
|
||||
# Run frontend tests (Vitest)
|
||||
pnpm test
|
||||
# Run in watch mode
|
||||
TZ=UTC pnpm test --watch
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
### Preview & Other
|
||||
|
||||
```bash
|
||||
# Preview production build locally
|
||||
pnpm preview
|
||||
# or
|
||||
make preview
|
||||
|
||||
# Run integration tests (Playwright)
|
||||
make int
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
The Go backend is organized into these key packages:
|
||||
|
||||
- **`internal/web/`** - HTTP server and routing layer
|
||||
- Routes defined in `routes.go` using chi router
|
||||
- WebSocket/SSE handlers for log streaming (`logs.go`)
|
||||
- Authentication middleware and token management (`auth.go`)
|
||||
- Container action handlers (`actions.go`)
|
||||
|
||||
- **`internal/docker/`** - Docker API client implementation
|
||||
- `client.go`: Main Docker client wrapper with container operations
|
||||
- `log_reader.go`: Streaming container logs
|
||||
- `stats_collector.go`: Real-time container stats collection
|
||||
|
||||
- **`internal/agent/`** - gRPC agent for multi-host support
|
||||
- Uses Protocol Buffers (protos defined in `protos/`)
|
||||
- Enables distributed log collection across Docker hosts
|
||||
|
||||
- **`internal/k8s/`** - Kubernetes client support
|
||||
- Alternative to Docker client for k8s deployments
|
||||
|
||||
- **`internal/support/`** - Support utilities
|
||||
- `cli/`: Command-line argument parsing and validation
|
||||
- `docker/`: Multi-host Docker management and Swarm support
|
||||
- `container/`: Container service abstractions
|
||||
- `web/`: Web service utilities
|
||||
|
||||
- **`internal/auth/`** - Authentication providers
|
||||
- Simple file-based auth (`simple.go`)
|
||||
- Forward proxy auth (`proxy.go`)
|
||||
- Role-based authorization (`roles.go`)
|
||||
|
||||
- **`internal/container/`** - Container domain models and interfaces
|
||||
|
||||
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s)
|
||||
|
||||
### Frontend (Vue 3)
|
||||
|
||||
The frontend uses file-based routing with these conventions:
|
||||
|
||||
- **`assets/pages/`** - File-based routes (unplugin-vue-router)
|
||||
- `container/[id].vue`: Single container view
|
||||
- `merged/[ids].vue`: Multi-container merged view
|
||||
- `host/[id].vue`: Host-level logs
|
||||
- `service/[name].vue`: Swarm service logs
|
||||
- `stack/[name].vue`: Docker stack logs
|
||||
- `group/[name].vue`: Custom grouped logs
|
||||
|
||||
- **`assets/components/`** - Vue components (auto-imported)
|
||||
- `LogViewer/`: Core log viewing components
|
||||
- `SimpleLogItem.vue`: Single-line log entries
|
||||
- `ComplexLogItem.vue`: JSON/structured log entries
|
||||
- `GroupedLogItem.vue`: Multi-line grouped log entries
|
||||
- `ContainerEventLogItem.vue`: Container lifecycle events
|
||||
- `SkippedEntriesLogItem.vue`: Placeholder for skipped logs
|
||||
- `LoadMoreLogItem.vue`: Load more historical logs
|
||||
- `ContainerViewer/`: Container-specific UI
|
||||
- `common/`: Reusable UI components
|
||||
- `BarChart.vue`: Lightweight bar chart with automatic downsampling
|
||||
- `HostCard.vue`: Host overview card with metrics
|
||||
- `MetricCard.vue`: Reusable metric display component
|
||||
- `ContainerTable.vue`: Container table with historical stat visualization
|
||||
|
||||
- **`assets/stores/`** - Pinia stores (auto-imported)
|
||||
- `config.ts`: App configuration and feature flags
|
||||
- `container.ts`: Container state management
|
||||
- `hosts.ts`: Multi-host state
|
||||
- `settings.ts`: User preferences
|
||||
|
||||
- **`assets/composable/`** - Vue composables (auto-imported)
|
||||
- `eventStreams.ts`: SSE connection management
|
||||
- `historicalLogs.ts`: Historical log fetching
|
||||
- `logContext.ts`: Log filtering and search context
|
||||
- `storage.ts`: LocalStorage abstractions
|
||||
- `visible.ts`: Log filtering by visible keys for complex logs
|
||||
|
||||
- **`assets/modules/`** - Vue plugins
|
||||
- `router.ts`: Vue Router configuration
|
||||
- `pinia.ts`: Pinia store setup
|
||||
- `i18n.ts`: Internationalization
|
||||
|
||||
### Communication Flow
|
||||
|
||||
1. **Real-time Logs**: Frontend establishes SSE connections to `/api/hosts/{host}/containers/{id}/logs/stream`
|
||||
2. **Container Events**: SSE stream at `/api/events/stream` pushes container lifecycle events
|
||||
3. **Stats**: Real-time CPU/memory stats streamed via SSE alongside events
|
||||
4. **Actions**: POST to `/api/hosts/{host}/containers/{id}/actions/{action}` (start/stop/restart)
|
||||
5. **Terminal**: WebSocket connections for container attach/exec at `/api/hosts/{host}/containers/{id}/attach`
|
||||
|
||||
### Build System
|
||||
|
||||
- **Frontend**: Vite builds to `dist/` with manifest
|
||||
- **Backend**: Embeds `dist/` using Go embed directive
|
||||
- **Hot Reload**: In development, `DEV=true` disables embedded assets, `LIVE_FS=true` serves from filesystem
|
||||
- **Makefile**: Orchestrates builds and dependency generation
|
||||
|
||||
## Important Development Notes
|
||||
|
||||
### Frontend
|
||||
|
||||
- Auto-imports are configured for Vue composables, components, and Pinia stores (see `vite.config.ts`)
|
||||
- Icons use unplugin-icons with multiple icon sets (mdi, carbon, material-symbols, etc.)
|
||||
- Tailwind CSS with DaisyUI for styling
|
||||
- TypeScript definitions auto-generated in `assets/auto-imports.d.ts` and `assets/components.d.ts`
|
||||
- **Log Entry Types**: Three types of log messages supported
|
||||
- `SimpleLogEntry`: Single-line text logs (`string`)
|
||||
- `ComplexLogEntry`: Structured JSON logs (`JSONObject`)
|
||||
- `GroupedLogEntry`: Multi-line grouped logs (`string[]`)
|
||||
- **Type consistency**: Use `LogMessage` type alias instead of `string | string[] | JSONObject` for log entry messages
|
||||
- **Charts/Visualizations**: Custom lightweight implementations (no D3.js)
|
||||
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
|
||||
- Downsampling algorithm: Averages data into buckets based on available screen width
|
||||
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
|
||||
|
||||
### Backend
|
||||
|
||||
- The application uses Go 1.25+ with module support
|
||||
- Certificate generation is required (`make generate` creates shared_key.pem and shared_cert.pem)
|
||||
- Protocol buffer generation happens via `go generate` directive in `main.go`
|
||||
- Docker client uses API version negotiation for compatibility
|
||||
|
||||
### Authentication
|
||||
|
||||
- Three modes: none, simple (file-based users.yml), forward-proxy (e.g., Authelia)
|
||||
- JWT tokens for simple auth with configurable TTL
|
||||
- User file location: `./data/users.yml` or `./data/users.yaml`
|
||||
|
||||
### Testing
|
||||
|
||||
- Go tests use standard `testing` package with testify assertions
|
||||
- Frontend uses Vitest with `@vue/test-utils`
|
||||
- Integration tests with Playwright in `e2e/`
|
||||
- Tests must run with `TZ=UTC` for consistent timestamps
|
||||
|
||||
### Container Stats & Metrics
|
||||
|
||||
- Stats are tracked using exponential moving average (EMA) with alpha=0.2
|
||||
- History stored in rolling window (300 items max) via `useSimpleRefHistory`
|
||||
- CPU metrics normalized by core count (respects `cpuLimit` or falls back to host `nCPU`)
|
||||
- Memory metrics include both percentage and absolute usage (`memoryUsage` vs `memory`)
|
||||
- Stats visualization uses adaptive downsampling for performance
|
||||
|
||||
### Container Labels
|
||||
|
||||
- `dev.dozzle.name`: Custom container display name
|
||||
- `dev.dozzle.group`: Group containers together
|
||||
- Label-based filtering throughout the application
|
||||
|
||||
### Deployment Modes
|
||||
|
||||
- **Server mode**: Single or multi-host Docker monitoring
|
||||
- **Swarm mode**: Automatic discovery of Swarm nodes via Docker API
|
||||
- **K8s mode**: Pod log monitoring in Kubernetes cluster
|
||||
- **Agent mode**: Lightweight gRPC agent for remote log collection
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:23.11.0-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY public ./public
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
|
||||
|
||||
# install gRPC dependencies
|
||||
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
|
||||
@@ -54,7 +54,7 @@ ARG TARGETOS TARGETARCH
|
||||
RUN go generate
|
||||
|
||||
# Build binary
|
||||
RUN GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ shared_key.pem:
|
||||
|
||||
shared_cert.pem: shared_key.pem
|
||||
@openssl req -new -key shared_key.pem -out shared_request.csr -subj "/C=US/ST=California/L=San Francisco/O=Dozzle"
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 365
|
||||
@rm shared_request.csr
|
||||
|
||||
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="Dozzle Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
# Dozzle - [dozzle.dev](https://dozzle.dev/)
|
||||
|
||||
Dozzle is a small lightweight application with a web based interface to monitor Docker logs. It doesn’t store any log files. It is for live monitoring of your container logs only.
|
||||
@@ -13,9 +9,6 @@ https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop) which is a top like application for monitoring Docker containers. It integrates with Dozzle to allow for linking directly to container logs.
|
||||
|
||||
## Features
|
||||
|
||||
- Intelligent fuzzy search for container names 🤖
|
||||
|
||||
Vendored
+364
-395
@@ -6,389 +6,374 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
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 acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
const asyncComputed: typeof import('@vueuse/core').asyncComputed
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
const computedEager: typeof import('@vueuse/core').computedEager
|
||||
const computedInject: typeof import('@vueuse/core').computedInject
|
||||
const computedWithControl: typeof import('@vueuse/core').computedWithControl
|
||||
const config: typeof import('./stores/config').default
|
||||
const controlledComputed: typeof import('@vueuse/core').controlledComputed
|
||||
const controlledRef: typeof import('@vueuse/core').controlledRef
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createDrawer: typeof import('./composable/drawer').createDrawer
|
||||
const createEventHook: typeof import('@vueuse/core').createEventHook
|
||||
const createGlobalState: typeof import('@vueuse/core').createGlobalState
|
||||
const createInjectionState: typeof import('@vueuse/core').createInjectionState
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
|
||||
const createRef: typeof import('@vueuse/core').createRef
|
||||
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
|
||||
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
|
||||
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
|
||||
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
|
||||
const customRef: typeof import('vue').customRef
|
||||
const dateLocale: typeof import('./stores/settings').dateLocale
|
||||
const debouncedRef: typeof import('@vueuse/core').debouncedRef
|
||||
const debouncedWatch: typeof import('@vueuse/core').debouncedWatch
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const defineStore: typeof import('pinia').defineStore
|
||||
const drawerContext: typeof import('./composable/drawer').drawerContext
|
||||
const eagerComputed: typeof import('@vueuse/core').eagerComputed
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const extendRef: typeof import('@vueuse/core').extendRef
|
||||
const flattenJSON: typeof import('./utils/index').flattenJSON
|
||||
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
|
||||
const formatBytes: typeof import('./utils/index').formatBytes
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const getDeep: typeof import('./utils/index').getDeep
|
||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const hourStyle: typeof import('./stores/settings').hourStyle
|
||||
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
|
||||
const inject: typeof import('vue').inject
|
||||
const injectLocal: typeof import('@vueuse/core').injectLocal
|
||||
const isDefined: typeof import('@vueuse/core').isDefined
|
||||
const isMobile: typeof import('./composable/media').isMobile
|
||||
const isObject: typeof import('./utils/index').isObject
|
||||
const isProxy: typeof import('vue').isProxy
|
||||
const isReactive: typeof import('vue').isReactive
|
||||
const isReadonly: typeof import('vue').isReadonly
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const lightTheme: typeof import('./stores/settings').lightTheme
|
||||
const loadBetween: typeof import('./composable/eventStreams').loadBetween
|
||||
const locale: typeof import('./stores/settings').locale
|
||||
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
|
||||
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
||||
const manualResetRef: typeof import('@vueuse/core')['manualResetRef']
|
||||
const mapActions: typeof import('pinia').mapActions
|
||||
const mapGetters: typeof import('pinia').mapGetters
|
||||
const mapState: typeof import('pinia').mapState
|
||||
const mapStores: typeof import('pinia').mapStores
|
||||
const mapWritableState: typeof import('pinia').mapWritableState
|
||||
const markRaw: typeof import('vue').markRaw
|
||||
const menuWidth: typeof import('./stores/settings').menuWidth
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onClickOutside: typeof import('@vueuse/core').onClickOutside
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onElementRemoval: typeof import('@vueuse/core').onElementRemoval
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onKeyStroke: typeof import('@vueuse/core').onKeyStroke
|
||||
const onLongPress: typeof import('@vueuse/core').onLongPress
|
||||
const onMounted: typeof import('vue').onMounted
|
||||
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||
const onStartTyping: typeof import('@vueuse/core').onStartTyping
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
const pinnedContainers: typeof import('./composable/storage').pinnedContainers
|
||||
const provide: typeof import('vue').provide
|
||||
const provideLocal: typeof import('@vueuse/core').provideLocal
|
||||
const provideLoggingContext: typeof import('./composable/logContext').provideLoggingContext
|
||||
const provideScrollContext: typeof import('./composable/scrollContext').provideScrollContext
|
||||
const reactify: typeof import('@vueuse/core').reactify
|
||||
const reactifyObject: typeof import('@vueuse/core').reactifyObject
|
||||
const reactive: typeof import('vue').reactive
|
||||
const reactiveComputed: typeof import('@vueuse/core').reactiveComputed
|
||||
const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
|
||||
const reactivePick: typeof import('@vueuse/core').reactivePick
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const refAutoReset: typeof import('@vueuse/core').refAutoReset
|
||||
const refDebounced: typeof import('@vueuse/core').refDebounced
|
||||
const refDefault: typeof import('@vueuse/core').refDefault
|
||||
const refManualReset: typeof import('@vueuse/core').refManualReset
|
||||
const refThrottled: typeof import('@vueuse/core').refThrottled
|
||||
const refWithControl: typeof import('@vueuse/core').refWithControl
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const resolveRef: typeof import('@vueuse/core').resolveRef
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings')['DEFAULT_SETTINGS']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const allLevels: typeof import('./composable/logContext')['allLevels']
|
||||
const arrayEquals: typeof import('./utils/index')['arrayEquals']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const automaticRedirect: typeof import('./stores/settings')['automaticRedirect']
|
||||
const collapseNav: typeof import('./stores/settings')['collapseNav']
|
||||
const compact: typeof import('./stores/settings')['compact']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const config: typeof import('./stores/config')['default']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createDrawer: typeof import('./composable/drawer')['createDrawer']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const dateLocale: typeof import('./stores/settings')['dateLocale']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const drawerContext: typeof import('./composable/drawer')['drawerContext']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const flattenJSON: typeof import('./utils/index')['flattenJSON']
|
||||
const flattenJSONToMap: typeof import('./utils/index')['flattenJSONToMap']
|
||||
const formatBytes: typeof import('./utils/index')['formatBytes']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const getDeep: typeof import('./utils/index')['getDeep']
|
||||
const globalShowPopup: typeof import('./composable/popup')['globalShowPopup']
|
||||
const h: typeof import('vue')['h']
|
||||
const hashCode: typeof import('./utils/index')['hashCode']
|
||||
const hourStyle: typeof import('./stores/settings')['hourStyle']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isMobile: typeof import('./composable/media')['isMobile']
|
||||
const isObject: typeof import('./utils/index')['isObject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const lightTheme: typeof import('./stores/settings')['lightTheme']
|
||||
const locale: typeof import('./stores/settings')['locale']
|
||||
const loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const menuWidth: typeof import('./stores/settings')['menuWidth']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
|
||||
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const provideLoggingContext: typeof import('./composable/logContext')['provideLoggingContext']
|
||||
const provideScrollContext: typeof import('./composable/scrollContext')['provideScrollContext']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const scrollContextKey: typeof import('./composable/scrollContext').scrollContextKey
|
||||
const search: typeof import('./stores/settings').search
|
||||
const sessionHost: typeof import('./composable/storage').sessionHost
|
||||
const setActivePinia: typeof import('pinia').setActivePinia
|
||||
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||
const setTitle: typeof import('./composable/title').setTitle
|
||||
const settings: typeof import('./stores/settings').settings
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const showAllContainers: typeof import('./stores/settings').showAllContainers
|
||||
const showStd: typeof import('./stores/settings').showStd
|
||||
const showTimestamp: typeof import('./stores/settings').showTimestamp
|
||||
const size: typeof import('./stores/settings').size
|
||||
const smallerScrollbars: typeof import('./stores/settings').smallerScrollbars
|
||||
const softWrap: typeof import('./stores/settings').softWrap
|
||||
const storeToRefs: typeof import('pinia').storeToRefs
|
||||
const stripVersion: typeof import('./utils/index').stripVersion
|
||||
const syncRef: typeof import('@vueuse/core').syncRef
|
||||
const syncRefs: typeof import('@vueuse/core').syncRefs
|
||||
const templateRef: typeof import('@vueuse/core').templateRef
|
||||
const throttledRef: typeof import('@vueuse/core').throttledRef
|
||||
const throttledWatch: typeof import('@vueuse/core').throttledWatch
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toReactive: typeof import('@vueuse/core').toReactive
|
||||
const toRef: typeof import('vue').toRef
|
||||
const toRefs: typeof import('vue').toRefs
|
||||
const toRelativeTime: typeof import('./utils/index').toRelativeTime
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
|
||||
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core').tryOnScopeDispose
|
||||
const tryOnUnmounted: typeof import('@vueuse/core').tryOnUnmounted
|
||||
const unref: typeof import('vue').unref
|
||||
const unrefElement: typeof import('@vueuse/core').unrefElement
|
||||
const until: typeof import('@vueuse/core').until
|
||||
const useActiveElement: typeof import('@vueuse/core').useActiveElement
|
||||
const useAnimate: typeof import('@vueuse/core').useAnimate
|
||||
const useAnnouncements: typeof import('./stores/announcements').useAnnouncements
|
||||
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
|
||||
const useArrayEvery: typeof import('@vueuse/core').useArrayEvery
|
||||
const useArrayFilter: typeof import('@vueuse/core').useArrayFilter
|
||||
const useArrayFind: typeof import('@vueuse/core').useArrayFind
|
||||
const useArrayFindIndex: typeof import('@vueuse/core').useArrayFindIndex
|
||||
const useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast
|
||||
const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes
|
||||
const useArrayJoin: typeof import('@vueuse/core').useArrayJoin
|
||||
const useArrayMap: typeof import('@vueuse/core').useArrayMap
|
||||
const useArrayReduce: typeof import('@vueuse/core').useArrayReduce
|
||||
const useArraySome: typeof import('@vueuse/core').useArraySome
|
||||
const useArrayUnique: typeof import('@vueuse/core').useArrayUnique
|
||||
const useAsyncQueue: typeof import('@vueuse/core').useAsyncQueue
|
||||
const useAsyncState: typeof import('@vueuse/core').useAsyncState
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useBase64: typeof import('@vueuse/core').useBase64
|
||||
const useBattery: typeof import('@vueuse/core').useBattery
|
||||
const useBluetooth: typeof import('@vueuse/core').useBluetooth
|
||||
const useBreakpoints: typeof import('@vueuse/core').useBreakpoints
|
||||
const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel
|
||||
const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation
|
||||
const useCached: typeof import('@vueuse/core').useCached
|
||||
const useClipboard: typeof import('@vueuse/core').useClipboard
|
||||
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||
const useCloned: typeof import('@vueuse/core').useCloned
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||
const useContainerStream: typeof import('./composable/eventStreams').useContainerStream
|
||||
const useCountdown: typeof import('@vueuse/core').useCountdown
|
||||
const useCounter: typeof import('@vueuse/core').useCounter
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssVar: typeof import('@vueuse/core').useCssVar
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
|
||||
const useCycleList: typeof import('@vueuse/core').useCycleList
|
||||
const useDark: typeof import('@vueuse/core').useDark
|
||||
const useDateFormat: typeof import('@vueuse/core').useDateFormat
|
||||
const useDebounce: typeof import('@vueuse/core').useDebounce
|
||||
const useDebounceFn: typeof import('@vueuse/core').useDebounceFn
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core').useDebouncedRefHistory
|
||||
const useDeviceMotion: typeof import('@vueuse/core').useDeviceMotion
|
||||
const useDeviceOrientation: typeof import('@vueuse/core').useDeviceOrientation
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core').useDevicePixelRatio
|
||||
const useDevicesList: typeof import('@vueuse/core').useDevicesList
|
||||
const useDisplayMedia: typeof import('@vueuse/core').useDisplayMedia
|
||||
const useDocumentVisibility: typeof import('@vueuse/core').useDocumentVisibility
|
||||
const useDownloadUrl: typeof import('./composable/downloadUrl').useDownloadUrl
|
||||
const useDraggable: typeof import('@vueuse/core').useDraggable
|
||||
const useDrawer: typeof import('./composable/drawer').useDrawer
|
||||
const useDropZone: typeof import('@vueuse/core').useDropZone
|
||||
const useDuckDB: typeof import('./composable/duckdb').useDuckDB
|
||||
const useElementBounding: typeof import('@vueuse/core').useElementBounding
|
||||
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
|
||||
const useElementHover: typeof import('@vueuse/core').useElementHover
|
||||
const useElementSize: typeof import('@vueuse/core').useElementSize
|
||||
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
|
||||
const useEventBus: typeof import('@vueuse/core').useEventBus
|
||||
const useEventListener: typeof import('@vueuse/core').useEventListener
|
||||
const useEventSource: typeof import('@vueuse/core').useEventSource
|
||||
const useExponentialMovingAverage: typeof import('./utils/index').useExponentialMovingAverage
|
||||
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
|
||||
const useFavicon: typeof import('@vueuse/core').useFavicon
|
||||
const useFetch: typeof import('@vueuse/core').useFetch
|
||||
const useFileDialog: typeof import('@vueuse/core').useFileDialog
|
||||
const useFileSystemAccess: typeof import('@vueuse/core').useFileSystemAccess
|
||||
const useFocus: typeof import('@vueuse/core').useFocus
|
||||
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
|
||||
const useFps: typeof import('@vueuse/core').useFps
|
||||
const useFullscreen: typeof import('@vueuse/core').useFullscreen
|
||||
const useGamepad: typeof import('@vueuse/core').useGamepad
|
||||
const useGeolocation: typeof import('@vueuse/core').useGeolocation
|
||||
const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream
|
||||
const useHead: typeof import('@vueuse/head').useHead
|
||||
const useHistoricalContainerLog: typeof import('./composable/historicalLogs').useHistoricalContainerLog
|
||||
const useHostStream: typeof import('./composable/eventStreams').useHostStream
|
||||
const useHosts: typeof import('./stores/hosts').useHosts
|
||||
const useI18n: typeof import('vue-i18n').useI18n
|
||||
const useId: typeof import('vue').useId
|
||||
const useIdle: typeof import('@vueuse/core').useIdle
|
||||
const useImage: typeof import('@vueuse/core').useImage
|
||||
const useInfiniteScroll: typeof import('@vueuse/core').useInfiniteScroll
|
||||
const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver
|
||||
const useInterval: typeof import('@vueuse/core').useInterval
|
||||
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
|
||||
const useK8sStore: typeof import('./stores/k8s').useK8sStore
|
||||
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
|
||||
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
|
||||
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
|
||||
const useMediaControls: typeof import('@vueuse/core').useMediaControls
|
||||
const useMediaQuery: typeof import('@vueuse/core').useMediaQuery
|
||||
const useMemoize: typeof import('@vueuse/core').useMemoize
|
||||
const useMemory: typeof import('@vueuse/core').useMemory
|
||||
const useMergedStream: typeof import('./composable/eventStreams').useMergedStream
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useMounted: typeof import('@vueuse/core').useMounted
|
||||
const useMouse: typeof import('@vueuse/core').useMouse
|
||||
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
|
||||
const useMousePressed: typeof import('@vueuse/core').useMousePressed
|
||||
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
|
||||
const useNamespaceStream: typeof import('./composable/eventStreams').useNamespaceStream
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
|
||||
const useNetwork: typeof import('@vueuse/core').useNetwork
|
||||
const useNow: typeof import('@vueuse/core').useNow
|
||||
const useObjectUrl: typeof import('@vueuse/core').useObjectUrl
|
||||
const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination
|
||||
const useOnline: typeof import('@vueuse/core').useOnline
|
||||
const useOwnerStream: typeof import('./composable/eventStreams').useOwnerStream
|
||||
const usePageLeave: typeof import('@vueuse/core').usePageLeave
|
||||
const useParallax: typeof import('@vueuse/core').useParallax
|
||||
const useParentElement: typeof import('@vueuse/core').useParentElement
|
||||
const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver
|
||||
const usePermission: typeof import('@vueuse/core').usePermission
|
||||
const usePinnedLogsStore: typeof import('./stores/pinned').usePinnedLogsStore
|
||||
const usePointer: typeof import('@vueuse/core').usePointer
|
||||
const usePointerLock: typeof import('@vueuse/core').usePointerLock
|
||||
const usePointerSwipe: typeof import('@vueuse/core').usePointerSwipe
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core').usePreferredColorScheme
|
||||
const usePreferredContrast: typeof import('@vueuse/core').usePreferredContrast
|
||||
const usePreferredDark: typeof import('@vueuse/core').usePreferredDark
|
||||
const usePreferredLanguages: typeof import('@vueuse/core').usePreferredLanguages
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
|
||||
const usePrevious: typeof import('@vueuse/core').usePrevious
|
||||
const useProfileStorage: typeof import('./composable/profileStorage').useProfileStorage
|
||||
const useRafFn: typeof import('@vueuse/core').useRafFn
|
||||
const useRefHistory: typeof import('@vueuse/core').useRefHistory
|
||||
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
|
||||
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
|
||||
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
|
||||
const useScriptTag: typeof import('@vueuse/core').useScriptTag
|
||||
const useScroll: typeof import('@vueuse/core').useScroll
|
||||
const useScrollContext: typeof import('./composable/scrollContext').useScrollContext
|
||||
const useScrollLock: typeof import('@vueuse/core').useScrollLock
|
||||
const useSearchFilter: typeof import('./composable/search').useSearchFilter
|
||||
const useSeoMeta: typeof import('@vueuse/head').useSeoMeta
|
||||
const useServiceStream: typeof import('./composable/eventStreams').useServiceStream
|
||||
const useSessionStorage: typeof import('@vueuse/core').useSessionStorage
|
||||
const useShare: typeof import('@vueuse/core').useShare
|
||||
const useSimpleRefHistory: typeof import('./utils/index').useSimpleRefHistory
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useSorted: typeof import('@vueuse/core').useSorted
|
||||
const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis
|
||||
const useStackStream: typeof import('./composable/eventStreams').useStackStream
|
||||
const useStepper: typeof import('@vueuse/core').useStepper
|
||||
const useStorage: typeof import('@vueuse/core').useStorage
|
||||
const useStorageAsync: typeof import('@vueuse/core').useStorageAsync
|
||||
const useStyleTag: typeof import('@vueuse/core').useStyleTag
|
||||
const useSupported: typeof import('@vueuse/core').useSupported
|
||||
const useSwarmStore: typeof import('./stores/swarm').useSwarmStore
|
||||
const useSwipe: typeof import('@vueuse/core').useSwipe
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const useTemplateRefsList: typeof import('@vueuse/core').useTemplateRefsList
|
||||
const useTextDirection: typeof import('@vueuse/core').useTextDirection
|
||||
const useTextSelection: typeof import('@vueuse/core').useTextSelection
|
||||
const useTextareaAutosize: typeof import('@vueuse/core').useTextareaAutosize
|
||||
const useThrottle: typeof import('@vueuse/core').useThrottle
|
||||
const useThrottleFn: typeof import('@vueuse/core').useThrottleFn
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core').useThrottledRefHistory
|
||||
const useTimeAgo: typeof import('@vueuse/core').useTimeAgo
|
||||
const useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
|
||||
const useTimeout: typeof import('@vueuse/core').useTimeout
|
||||
const useTimeoutFn: typeof import('@vueuse/core').useTimeoutFn
|
||||
const useTimeoutPoll: typeof import('@vueuse/core').useTimeoutPoll
|
||||
const useTimestamp: typeof import('@vueuse/core').useTimestamp
|
||||
const useTitle: typeof import('@vueuse/core').useTitle
|
||||
const useToNumber: typeof import('@vueuse/core').useToNumber
|
||||
const useToString: typeof import('@vueuse/core').useToString
|
||||
const useToast: typeof import('./composable/toast').useToast
|
||||
const useToggle: typeof import('@vueuse/core').useToggle
|
||||
const useTransition: typeof import('@vueuse/core').useTransition
|
||||
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
|
||||
const useUserMedia: typeof import('@vueuse/core').useUserMedia
|
||||
const useVModel: typeof import('@vueuse/core').useVModel
|
||||
const useVModels: typeof import('@vueuse/core').useVModels
|
||||
const useVibrate: typeof import('@vueuse/core').useVibrate
|
||||
const useVirtualList: typeof import('@vueuse/core').useVirtualList
|
||||
const useVisibleFilter: typeof import('./composable/visible').useVisibleFilter
|
||||
const useWakeLock: typeof import('@vueuse/core').useWakeLock
|
||||
const useWebNotification: typeof import('@vueuse/core').useWebNotification
|
||||
const useWebSocket: typeof import('@vueuse/core').useWebSocket
|
||||
const useWebWorker: typeof import('@vueuse/core').useWebWorker
|
||||
const useWebWorkerFn: typeof import('@vueuse/core').useWebWorkerFn
|
||||
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
|
||||
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
|
||||
const useWindowSize: typeof import('@vueuse/core').useWindowSize
|
||||
const watch: typeof import('vue').watch
|
||||
const watchArray: typeof import('@vueuse/core').watchArray
|
||||
const watchAtMost: typeof import('@vueuse/core').watchAtMost
|
||||
const watchDebounced: typeof import('@vueuse/core').watchDebounced
|
||||
const watchDeep: typeof import('@vueuse/core').watchDeep
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchIgnorable: typeof import('@vueuse/core').watchIgnorable
|
||||
const watchImmediate: typeof import('@vueuse/core').watchImmediate
|
||||
const watchOnce: typeof import('@vueuse/core').watchOnce
|
||||
const watchPausable: typeof import('@vueuse/core').watchPausable
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
const watchThrottled: typeof import('@vueuse/core').watchThrottled
|
||||
const watchTriggerable: typeof import('@vueuse/core').watchTriggerable
|
||||
const watchWithFilter: typeof import('@vueuse/core').watchWithFilter
|
||||
const whenever: typeof import('@vueuse/core').whenever
|
||||
const withBase: typeof import('./stores/config').withBase
|
||||
const scrollContextKey: typeof import('./composable/scrollContext')['scrollContextKey']
|
||||
const search: typeof import('./stores/settings')['search']
|
||||
const sessionHost: typeof import('./composable/storage')['sessionHost']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const setTitle: typeof import('./composable/title')['setTitle']
|
||||
const settings: typeof import('./stores/settings')['settings']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const showAllContainers: typeof import('./stores/settings')['showAllContainers']
|
||||
const showStd: typeof import('./stores/settings')['showStd']
|
||||
const showTimestamp: typeof import('./stores/settings')['showTimestamp']
|
||||
const size: typeof import('./stores/settings')['size']
|
||||
const smallerScrollbars: typeof import('./stores/settings')['smallerScrollbars']
|
||||
const softWrap: typeof import('./stores/settings')['softWrap']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const stripVersion: typeof import('./utils/index')['stripVersion']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useAnnouncements: typeof import('./stores/announcements')['useAnnouncements']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
||||
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
||||
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useContainerActions: typeof import('./composable/containerActions')['useContainerActions']
|
||||
const useContainerStore: typeof import('./stores/container')['useContainerStore']
|
||||
const useContainerStream: typeof import('./composable/eventStreams')['useContainerStream']
|
||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDrawer: typeof import('./composable/drawer')['useDrawer']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useDuckDB: typeof import('./composable/duckdb')['useDuckDB']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useExponentialMovingAverage: typeof import('./utils/index')['useExponentialMovingAverage']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useHostStream: typeof import('./composable/eventStreams')['useHostStream']
|
||||
const useHosts: typeof import('./stores/hosts')['useHosts']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useLoggingContext: typeof import('./composable/logContext')['useLoggingContext']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMergedStream: typeof import('./composable/eventStreams')['useMergedStream']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePinnedLogsStore: typeof import('./stores/pinned')['usePinnedLogsStore']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useProfileStorage: typeof import('./composable/profileStorage')['useProfileStorage']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollContext: typeof import('./composable/scrollContext')['useScrollContext']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSearchFilter: typeof import('./composable/search')['useSearchFilter']
|
||||
const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta']
|
||||
const useServiceStream: typeof import('./composable/eventStreams')['useServiceStream']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStackStream: typeof import('./composable/eventStreams')['useStackStream']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwarmStore: typeof import('./stores/swarm')['useSwarmStore']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
||||
const useToString: typeof import('@vueuse/core')['useToString']
|
||||
const useToast: typeof import('./composable/toast')['useToast']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useVisibleFilter: typeof import('./composable/visible')['useVisibleFilter']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchDeep: typeof import('@vueuse/core')['watchDeep']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
const withBase: typeof import('./stores/config')['withBase']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
@@ -403,9 +388,6 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
import('./stores/settings')
|
||||
}
|
||||
@@ -417,8 +399,6 @@ declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./stores/settings')['DEFAULT_SETTINGS']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly K8sNamespace: UnwrapRef<typeof import('./stores/k8s')['K8sNamespace']>
|
||||
readonly K8sOwner: UnwrapRef<typeof import('./stores/k8s')['K8sOwner']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly allLevels: UnwrapRef<typeof import('./composable/logContext')['allLevels']>
|
||||
readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']>
|
||||
@@ -464,10 +444,8 @@ declare module 'vue' {
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -481,9 +459,7 @@ declare module 'vue' {
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
|
||||
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
|
||||
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
|
||||
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
@@ -534,11 +510,11 @@ declare module 'vue' {
|
||||
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
|
||||
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
|
||||
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
|
||||
readonly refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']>
|
||||
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
||||
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
||||
readonly scrollContextKey: UnwrapRef<typeof import('./composable/scrollContext')['scrollContextKey']>
|
||||
readonly search: UnwrapRef<typeof import('./stores/settings')['search']>
|
||||
readonly sessionHost: UnwrapRef<typeof import('./composable/storage')['sessionHost']>
|
||||
@@ -566,7 +542,6 @@ declare module 'vue' {
|
||||
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toRelativeTime: UnwrapRef<typeof import('./utils/index')['toRelativeTime']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
|
||||
@@ -628,7 +603,6 @@ declare module 'vue' {
|
||||
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
|
||||
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
|
||||
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
|
||||
readonly useDownloadUrl: UnwrapRef<typeof import('./composable/downloadUrl')['useDownloadUrl']>
|
||||
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
|
||||
readonly useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']>
|
||||
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
|
||||
@@ -655,7 +629,6 @@ declare module 'vue' {
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
|
||||
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
|
||||
readonly useHistoricalContainerLog: UnwrapRef<typeof import('./composable/historicalLogs')['useHistoricalContainerLog']>
|
||||
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
|
||||
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
@@ -666,7 +639,6 @@ declare module 'vue' {
|
||||
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
|
||||
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
|
||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||
readonly useK8sStore: UnwrapRef<typeof import('./stores/k8s')['useK8sStore']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
@@ -684,14 +656,12 @@ declare module 'vue' {
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
||||
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
||||
readonly useNamespaceStream: UnwrapRef<typeof import('./composable/eventStreams')['useNamespaceStream']>
|
||||
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
||||
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
||||
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
||||
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
|
||||
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
|
||||
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
|
||||
readonly useOwnerStream: UnwrapRef<typeof import('./composable/eventStreams')['useOwnerStream']>
|
||||
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
|
||||
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
|
||||
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
||||
@@ -748,7 +718,6 @@ declare module 'vue' {
|
||||
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
||||
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
||||
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
|
||||
readonly useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
|
||||
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
||||
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||
|
||||
Vendored
+9
-30
@@ -1,20 +1,17 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Announcements: typeof import('./components/Announcements.vue')['default']
|
||||
BarChart: typeof import('./components/BarChart.vue')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
|
||||
'Carbon:information': typeof import('~icons/carbon/information')['default']
|
||||
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
|
||||
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
|
||||
@@ -37,79 +34,61 @@ declare module 'vue' {
|
||||
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
|
||||
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
|
||||
ContainerPopup: typeof import('./components/ContainerPopup.vue')['default']
|
||||
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
|
||||
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
GroupedLogItem: typeof import('./components/LogViewer/GroupedLogItem.vue')['default']
|
||||
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
|
||||
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
|
||||
HostCard: typeof import('./components/HostCard.vue')['default']
|
||||
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
|
||||
HostList: typeof import('./components/HostList.vue')['default']
|
||||
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
|
||||
HostMenu: typeof import('./components/HostMenu.vue')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
|
||||
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
|
||||
K8sMenu: typeof import('./components/K8sMenu.vue')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
|
||||
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
|
||||
Links: typeof import('./components/Links.vue')['default']
|
||||
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
|
||||
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
|
||||
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
|
||||
LogItem: typeof import('./components/LogViewer/LogItem.vue')['default']
|
||||
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
|
||||
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
|
||||
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
|
||||
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||
'MaterialSymbols:codeBlocksRounded': typeof import('~icons/material-symbols/code-blocks-rounded')['default']
|
||||
'MaterialSymbols:contentCopy': typeof import('~icons/material-symbols/content-copy')['default']
|
||||
'MaterialSymbols:eyeTracking': typeof import('~icons/material-symbols/eye-tracking')['default']
|
||||
'MaterialSymbols:link': typeof import('~icons/material-symbols/link')['default']
|
||||
'MaterialSymbols:logout': typeof import('~icons/material-symbols/logout')['default']
|
||||
'MaterialSymbols:person': typeof import('~icons/material-symbols/person')['default']
|
||||
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
|
||||
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
|
||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||
'Mdi:check': typeof import('~icons/mdi/check')['default']
|
||||
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
|
||||
'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default']
|
||||
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
|
||||
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
|
||||
'Mdi:close': typeof import('~icons/mdi/close')['default']
|
||||
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
|
||||
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
|
||||
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
|
||||
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
|
||||
'Mdi:github': typeof import('~icons/mdi/github')['default']
|
||||
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
|
||||
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
|
||||
'Mdi:key': typeof import('~icons/mdi/key')['default']
|
||||
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
|
||||
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
|
||||
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
|
||||
MetricCard: typeof import('./components/MetricCard.vue')['default']
|
||||
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
|
||||
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
|
||||
MultiContainerLog: typeof import('./components/MultiContainerViewer/MultiContainerLog.vue')['default']
|
||||
MultiContainerStat: typeof import('./components/LogViewer/MultiContainerStat.vue')['default']
|
||||
NamespaceLog: typeof import('./components/K8sViewer/NamespaceLog.vue')['default']
|
||||
'Octicon:container24': typeof import('~icons/octicon/container24')['default']
|
||||
'Octicon:download24': typeof import('~icons/octicon/download24')['default']
|
||||
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
|
||||
OwnerLog: typeof import('./components/K8sViewer/OwnerLog.vue')['default']
|
||||
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
|
||||
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
|
||||
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
|
||||
@@ -117,16 +96,15 @@ declare module 'vue' {
|
||||
'Ph:command': typeof import('~icons/ph/command')['default']
|
||||
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
|
||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:memory': typeof import('~icons/ph/memory')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
PhArrowDown: typeof import('~icons/ph/arrow-down')['default']
|
||||
PhArrowUp: typeof import('~icons/ph/arrow-up')['default']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
|
||||
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
@@ -143,6 +121,7 @@ declare module 'vue' {
|
||||
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
Terminal: typeof import('./components/Terminal.vue')['default']
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
class="dropdown-end"
|
||||
@click="config.releaseCheckMode === 'manual' && fetchReleases()"
|
||||
@closed="releaseSeen = mostRecent?.tag ?? config.version"
|
||||
>
|
||||
<Dropdown class="dropdown-end" @closed="releaseSeen = mostRecent?.tag ?? config.version">
|
||||
<template #trigger>
|
||||
<mdi:announcement class="size-6 -rotate-12" />
|
||||
<template v-if="announcements.length > 0 && releaseSeen != mostRecent?.tag">
|
||||
@@ -26,7 +22,7 @@
|
||||
>
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><RelativeTime :date="release.createdAt" /></span>
|
||||
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
|
||||
</div>
|
||||
<div class="text-base-content/80 text-sm">
|
||||
{{ release.body }}
|
||||
@@ -43,7 +39,7 @@
|
||||
>
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><RelativeTime :date="release.createdAt" /></span>
|
||||
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
|
||||
<Tag class="bg-red ml-auto px-1 py-1 text-xs" v-if="release.latest">
|
||||
{{ $t("releases.latest") }}
|
||||
</Tag>
|
||||
@@ -67,7 +63,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAnnouncements } from "@/stores/announcements";
|
||||
|
||||
const { announcements, mostRecent, fetchReleases } = useAnnouncements();
|
||||
const { announcements, mostRecent } = useAnnouncements();
|
||||
const { t } = useI18n();
|
||||
|
||||
const releaseSeen = useProfileStorage("releaseSeen", config.version);
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div ref="chartContainer" class="flex items-end gap-[2px]" @mousemove="onContainerHover">
|
||||
<div
|
||||
v-for="(dataPoint, i) in downsampledData"
|
||||
:key="i"
|
||||
class="bar min-h-px flex-1 rounded-t-sm"
|
||||
:class="barClass"
|
||||
:style="{ '--height': `${Math.min(dataPoint, 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
height: var(--height);
|
||||
will-change: height;
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { chartData, barClass = "" } = defineProps<{
|
||||
chartData: number[];
|
||||
barClass?: string;
|
||||
}>();
|
||||
|
||||
const hoverIndex = defineEmit<[startIndex: number, endIndex: number]>();
|
||||
|
||||
const chartContainer = ref<HTMLElement | null>(null);
|
||||
const { width } = useElementSize(chartContainer);
|
||||
|
||||
const BAR_WIDTH = 3;
|
||||
const GAP = 2;
|
||||
|
||||
const availableBars = computed(() => Math.floor(width.value / (BAR_WIDTH + GAP)));
|
||||
const bucketSize = computed(() => Math.ceil(chartData.length / availableBars.value));
|
||||
|
||||
const downsampledData = ref<number[]>([]);
|
||||
const changeCounter = ref(-1);
|
||||
|
||||
// Watch chartData changes
|
||||
watch(
|
||||
() => chartData,
|
||||
() => {
|
||||
// If changeCounter is -1, it means this is the first time the data is loaded
|
||||
if (changeCounter.value === -1) {
|
||||
recalculate();
|
||||
}
|
||||
changeCounter.value++;
|
||||
if (changeCounter.value >= bucketSize.value) {
|
||||
// Recalculate when counter reaches bucket size
|
||||
recalculate();
|
||||
changeCounter.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recalculate when width changes
|
||||
watch([availableBars, bucketSize], () => {
|
||||
recalculate();
|
||||
changeCounter.value = -1;
|
||||
});
|
||||
|
||||
function recalculate() {
|
||||
if (chartData.length <= availableBars.value || availableBars.value === 0) {
|
||||
downsampledData.value = [...chartData];
|
||||
return;
|
||||
}
|
||||
|
||||
const size = bucketSize.value;
|
||||
const result = [];
|
||||
|
||||
// Create complete buckets
|
||||
const numCompleteBuckets = Math.floor(chartData.length / size);
|
||||
|
||||
for (let i = 0; i < numCompleteBuckets; i++) {
|
||||
const start = i * size;
|
||||
const end = start + size;
|
||||
const bucket = chartData.slice(start, end);
|
||||
const avg = bucket.reduce((sum, val) => sum + val, 0) / bucket.length;
|
||||
result.push(avg);
|
||||
}
|
||||
|
||||
// Show only the last N bars that fit on screen
|
||||
downsampledData.value = result.slice(-availableBars.value);
|
||||
}
|
||||
|
||||
function onContainerHover(event: MouseEvent) {
|
||||
if (!chartContainer.value) return;
|
||||
|
||||
const rect = chartContainer.value.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
|
||||
// Calculate which bar the mouse is over based on position
|
||||
const barWidth = width.value / downsampledData.value.length;
|
||||
const index = Math.floor(x / barWidth);
|
||||
|
||||
// Ensure index is within bounds
|
||||
if (index < 0 || index >= downsampledData.value.length) return;
|
||||
|
||||
// Map downsampled index back to original data index range
|
||||
const numCompleteBuckets = Math.floor(chartData.length / bucketSize.value);
|
||||
const offset = Math.max(0, numCompleteBuckets - availableBars.value);
|
||||
const startIndex = (offset + index) * bucketSize.value;
|
||||
const endIndex = Math.min(startIndex + bucketSize.value - 1, chartData.length - 1);
|
||||
hoverIndex(startIndex, endIndex);
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm"><slot /> <carbon:caret-down /></button>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm">
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 shadow-sm">
|
||||
<li v-for="other in containers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }" class="text-nowrap">
|
||||
<div
|
||||
@@ -10,7 +10,7 @@
|
||||
></div>
|
||||
{{ other.name }}
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
<DistanceTime :date="other.created" strict class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
<template>
|
||||
<table class="w-full border-separate border-spacing-x-1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-right font-light capitalize">STATE</th>
|
||||
<td class="font-semibold uppercase">{{ container.state }}</td>
|
||||
</tr>
|
||||
<tr v-if="container.startedAt.getFullYear() > 0">
|
||||
<th class="text-right font-light capitalize">STARTED</th>
|
||||
<td class="font-semibold">
|
||||
<RelativeTime :date="container.startedAt" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
|
||||
<th class="text-right font-light capitalize">FINISHED</th>
|
||||
<td class="font-semibold">
|
||||
<RelativeTime :date="container.finishedAt" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="container.state == 'running'">
|
||||
<th class="text-right font-light capitalize">Load</th>
|
||||
<td class="font-semibold">{{ container.stat.cpu.toFixed(2) }}%</td>
|
||||
</tr>
|
||||
<tr v-if="container.state == 'running'">
|
||||
<th class="text-right font-light capitalize">MEM</th>
|
||||
<td class="font-semibold">{{ formatBytes(container.stat.memoryUsage) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<span class="font-light capitalize"> STATE </span>
|
||||
<span class="font-semibold uppercase"> {{ container.state }} </span>
|
||||
</div>
|
||||
<div v-if="container.startedAt.getFullYear() > 0">
|
||||
<span class="font-light capitalize"> STARTED </span>
|
||||
<span class="font-semibold">
|
||||
<DistanceTime :date="container.startedAt" strict />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
|
||||
<span class="font-light capitalize"> FINISHED </span>
|
||||
<span class="font-semibold">
|
||||
<DistanceTime :date="container.finishedAt" strict />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="container.state == 'running'">
|
||||
<span class="font-light capitalize"> Load </span>
|
||||
<span class="font-semibold"> {{ container.stat.cpu.toFixed(2) }}% </span>
|
||||
</div>
|
||||
<div v-if="container.state == 'running'">
|
||||
<span class="font-light capitalize"> MEM </span>
|
||||
<span class="font-semibold"> {{ formatBytes(container.stat.memoryUsage) }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
|
||||
<span class="w-fit text-right text-sm">{{ displayValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Container } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
|
||||
const { container, type, host } = defineProps<{
|
||||
container: Container;
|
||||
type: "cpu" | "mem";
|
||||
host: Host;
|
||||
}>();
|
||||
|
||||
function totalCores(): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
}
|
||||
return host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return container.statsHistory.map((stat) => Math.min(stat.cpu / cores, 100));
|
||||
}
|
||||
return container.statsHistory.map((stat) => Math.min(stat.memory, 100));
|
||||
});
|
||||
|
||||
const averageValue = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return Math.min(container.movingAverage.cpu / cores, 100);
|
||||
}
|
||||
return container.movingAverage.memory;
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (type === "cpu") {
|
||||
return `${averageValue.value.toFixed(0)}%`;
|
||||
}
|
||||
return formatBytes(container.movingAverage.memoryUsage);
|
||||
});
|
||||
|
||||
const barClass = computed(() => {
|
||||
const value = averageValue.value;
|
||||
if (value <= 50) return "bg-success";
|
||||
if (value <= 70) return "bg-secondary";
|
||||
if (value <= 90) return "bg-warning";
|
||||
return "bg-error";
|
||||
});
|
||||
</script>
|
||||
@@ -53,7 +53,7 @@
|
||||
v-for="(value, key) in fields"
|
||||
:key="key"
|
||||
@click.prevent="sort(key)"
|
||||
:class="[value.customClass, { 'selected-sort': key === sortField }]"
|
||||
:class="{ 'selected-sort': key === sortField }"
|
||||
v-show="isVisible(key)"
|
||||
>
|
||||
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
|
||||
@@ -66,8 +66,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-base-300/30">
|
||||
<tr v-for="container in paginated" :key="container.id" v-memo="[container.id]" class="hover:bg-base-100/80!">
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate">
|
||||
<tr v-for="container in paginated" :key="container.id" class="hover:bg-base-100/80!">
|
||||
<td v-if="isVisible('name')">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
@@ -75,20 +75,34 @@
|
||||
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
|
||||
<td v-if="isVisible('state')">{{ container.state }}</td>
|
||||
<td v-if="isVisible('created')">
|
||||
<RelativeTime :date="container.created" />
|
||||
<distance-time :date="container.created" strict :suffix="false"></distance-time>
|
||||
</td>
|
||||
<td v-if="isVisible('cpu')">
|
||||
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" />
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<progress
|
||||
class="progress progress-primary"
|
||||
:value="Math.min(container.movingAverage.cpu, 100)"
|
||||
:max="100"
|
||||
></progress>
|
||||
<span class="text-sm">{{ container.movingAverage.cpu.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="isVisible('mem')">
|
||||
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" />
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<progress
|
||||
class="progress progress-primary"
|
||||
:value="container.movingAverage.memory"
|
||||
max="100"
|
||||
></progress>
|
||||
<span class="text-sm">{{ container.movingAverage.memory.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-4 text-center">
|
||||
<nav class="join" v-if="isPaginated && totalPages <= 15">
|
||||
<nav class="join" v-if="isPaginated">
|
||||
<input
|
||||
class="btn btn-square join-item"
|
||||
type="radio"
|
||||
@@ -98,12 +112,6 @@
|
||||
v-for="i in totalPages"
|
||||
/>
|
||||
</nav>
|
||||
<DropdownMenu
|
||||
v-else-if="isPaginated"
|
||||
class="btn-sm"
|
||||
v-model="currentPage"
|
||||
:options="Array.from({ length: totalPages }, (_, i) => ({ label: `${i + 1}`, value: i + 1 }))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -115,15 +123,7 @@ import { toRefs } from "@vueuse/core";
|
||||
const { hosts } = useHosts();
|
||||
const selectedHost = ref(null);
|
||||
|
||||
const fields: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
sortFunc: (a: Container, b: Container) => number;
|
||||
mobileVisible: boolean;
|
||||
customClass?: string;
|
||||
}
|
||||
> = {
|
||||
const fields = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
@@ -133,32 +133,26 @@ const fields: Record<
|
||||
label: "label.host",
|
||||
sortFunc: (a: Container, b: Container) => a.hostLabel.localeCompare(b.hostLabel) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
state: {
|
||||
label: "label.status",
|
||||
sortFunc: (a: Container, b: Container) => a.state.localeCompare(b.state) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
created: {
|
||||
label: "label.created",
|
||||
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
|
||||
mobileVisible: true,
|
||||
customClass: "w-1",
|
||||
},
|
||||
cpu: {
|
||||
label: "label.avg-cpu",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
},
|
||||
mem: {
|
||||
label: "label.avg-mem",
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.movingAverage.memoryUsage - b.movingAverage.memoryUsage) * direction.value,
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.memory - b.movingAverage.memory) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,10 +165,10 @@ const perPage = useStorage("DOZZLE_TABLE_PAGE_SIZE", 15);
|
||||
const pageSizes = [15, 30, 50, 100];
|
||||
|
||||
const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CONTAINERS_SORT", {
|
||||
column: "created" as keys,
|
||||
direction: -1 as 1 | -1,
|
||||
column: "created",
|
||||
direction: -1,
|
||||
});
|
||||
const { column: sortField, direction } = toRefs(storage.value);
|
||||
const { column: sortField, direction } = toRefs(storage);
|
||||
const counter = useInterval(10000);
|
||||
const filteredContainers = computed(() =>
|
||||
containers.filter((c) => selectedHost.value === null || c.host === selectedHost.value),
|
||||
@@ -231,6 +225,9 @@ th {
|
||||
}
|
||||
|
||||
tbody td {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-10 gap-0.5 px-2">
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="!historical">
|
||||
<a @click="clear()">
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="enableDownload">
|
||||
<a :href="downloadUrl" download>
|
||||
<octicon:download-24 />
|
||||
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
|
||||
</a>
|
||||
<li>
|
||||
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
|
||||
</li>
|
||||
<li v-if="!historical">
|
||||
<a @click="showSearch = true">
|
||||
<li>
|
||||
<a @click.prevent="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
<KeyShortcut char="f" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="hasComplexLogs">
|
||||
<a @click="showDrawer(LogAnalytics, { container }, 'lg')">
|
||||
<a @click.prevent="showDrawer(LogAnalytics, { container }, 'lg')">
|
||||
<ph:file-sql /> SQL Analytics
|
||||
<KeyShortcut char="f" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
@@ -110,7 +103,7 @@
|
||||
</li>
|
||||
|
||||
<!-- Container Actions (Enabled via config) -->
|
||||
<template v-if="enableActions && !historical">
|
||||
<template v-if="enableActions">
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<button
|
||||
@@ -142,19 +135,17 @@
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template v-if="enableShell && !historical">
|
||||
<template v-if="enableShell">
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<a @click="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
|
||||
<ri:terminal-window-fill />
|
||||
{{ $t("toolbar.attach") }}
|
||||
<a @click.prevent="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
|
||||
<ri:terminal-window-fill /> Attach
|
||||
<KeyShortcut char="a" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
|
||||
<material-symbols:terminal />
|
||||
{{ $t("toolbar.shell") }}
|
||||
<a @click.prevent="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
|
||||
<material-symbols:terminal /> Shell
|
||||
<KeyShortcut char="e" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
@@ -170,11 +161,11 @@ import LogAnalytics from "../LogViewer/LogAnalytics.vue";
|
||||
import Terminal from "@/components/Terminal.vue";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { enableActions, enableShell, enableDownload } = config;
|
||||
const { enableActions, enableShell } = config;
|
||||
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
|
||||
const showDrawer = useDrawer();
|
||||
|
||||
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
|
||||
|
||||
@@ -202,8 +193,17 @@ if (enableShell) {
|
||||
});
|
||||
}
|
||||
|
||||
const containerRef = computed(() => [container]);
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels);
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
|
||||
);
|
||||
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/containers/${container.host}~${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
|
||||
|
||||
@@ -217,16 +217,6 @@ const toggleAllLevels = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="@container mx-2 flex items-center gap-1 md:ml-4 md:gap-2">
|
||||
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
|
||||
<ContainerTitle :container="container" />
|
||||
<MultiContainerStat
|
||||
class="ml-auto lg:hidden lg:@3xl:flex"
|
||||
@@ -9,7 +9,7 @@
|
||||
v-if="container.state === 'running'"
|
||||
/>
|
||||
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" :container="container" />
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" class="max-md:hidden" :container="container" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
|
||||
{{ container.name }} <carbon:caret-down />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm"
|
||||
>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 shadow-sm">
|
||||
<li v-for="other in otherContainers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
|
||||
<div
|
||||
@@ -31,7 +28,7 @@
|
||||
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div v-else>{{ other.name }}</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
<DistanceTime :date="other.created" strict class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
|
||||
<ContainerTitle :container="container" />
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: container.id } }"
|
||||
class="btn btn-secondary btn-sm"
|
||||
v-if="container.state === 'running'"
|
||||
>
|
||||
<mdi:lightning-bolt />
|
||||
Live Logs
|
||||
</router-link>
|
||||
|
||||
<ContainerActionsToolbar class="max-md:hidden" :container="container" historical />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useHistoricalContainerLog"
|
||||
:entity="historicalContainer"
|
||||
:visible-keys="visibleKeys"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { HistoricalContainer } from "@/models/Container";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const {
|
||||
id,
|
||||
showTitle = false,
|
||||
scrollable = false,
|
||||
closable = false,
|
||||
date,
|
||||
} = defineProps<{
|
||||
id: string;
|
||||
showTitle?: boolean;
|
||||
scrollable?: boolean;
|
||||
closable?: boolean;
|
||||
date: Date;
|
||||
}>();
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer(toRef(() => id));
|
||||
const historicalContainer = toRef(() => new HistoricalContainer(container.value, date));
|
||||
const visibleKeys = persistentVisibleKeysForContainer(container);
|
||||
useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => [container.value]),
|
||||
{ showContainerName: false, showHostname: false, historical: true },
|
||||
);
|
||||
</script>
|
||||
@@ -53,7 +53,7 @@
|
||||
<span data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
|
||||
<RelativeTime :date="result.item.created" class="text-xs font-light" />
|
||||
<DistanceTime :date="result.item.created" class="text-xs font-light" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100">
|
||||
<div class="card-body flex gap-2 max-md:p-4">
|
||||
<div class="flex flex-row gap-2 overflow-hidden">
|
||||
<div class="flex items-center gap-1 truncate text-xl font-semibold">
|
||||
<HostIcon :type="host.type" class="flex-none" />
|
||||
<div class="truncate">
|
||||
{{ host.name }}
|
||||
</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-2 p-2"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
>
|
||||
{{ host.agentVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="ml-auto flex flex-row flex-wrap gap-x-2 text-sm max-md:text-xs md:gap-3">
|
||||
<li class="flex items-center gap-1">
|
||||
<octicon:container-24 class="inline-block" />
|
||||
{{ $t("label.container", hostContainers.length) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 md:gap-3" v-if="stats">
|
||||
<MetricCard
|
||||
:icon="PhCpu"
|
||||
:value="stats.weighted.movingAverage.totalCPU"
|
||||
:chartData="cpuHistory"
|
||||
container-class="border-primary/40 bg-primary/20"
|
||||
text-class="text-primary"
|
||||
bar-class="bg-primary"
|
||||
:formatValue="(value) => `${value.toFixed(1)}%`"
|
||||
:label="`${host.nCPU} CPU`"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
:icon="PhMemory"
|
||||
:value="stats.weighted.movingAverage.totalMemUsage"
|
||||
:chartData="memHistory"
|
||||
container-class="border-secondary/40 bg-secondary/20"
|
||||
text-class="text-secondary"
|
||||
bar-class="bg-secondary"
|
||||
:formatValue="(value) => formatBytes(value, { decimals: 1 })"
|
||||
:label="formatBytes(host.memTotal, { decimals: 1 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
host: Host;
|
||||
}>();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const hostContainers = computed(() =>
|
||||
containers.value.filter((container) => container.host === props.host.id && container.state === "running"),
|
||||
);
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
}
|
||||
return props.host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
totalMemUsage: number;
|
||||
};
|
||||
|
||||
const totalStat = ref<TotalStat>({ totalCPU: 0, totalMem: 0, totalMemUsage: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
|
||||
const cpuHistory = computed(() =>
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.totalCPU,
|
||||
value: stat.totalCPU,
|
||||
})),
|
||||
);
|
||||
const memHistory = computed(() =>
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.totalMem,
|
||||
value: stat.totalMemUsage,
|
||||
})),
|
||||
);
|
||||
|
||||
const stats = reactive({ mostRecent: totalStat, weighted: useExponentialMovingAverage(totalStat) });
|
||||
|
||||
watch(
|
||||
() => hostContainers.value,
|
||||
() => {
|
||||
const initial: TotalStat[] = [];
|
||||
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = hostContainers.value.reduce(
|
||||
(acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
totalCPU: acc.totalCPU + item.cpu / cores,
|
||||
totalMem: acc.totalMem + item.memory,
|
||||
totalMemUsage: acc.totalMemUsage + item.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ totalCPU: 0, totalMem: 0, totalMemUsage: 0 },
|
||||
);
|
||||
initial.push(stat);
|
||||
}
|
||||
reset({ initial: initial.reverse() });
|
||||
stats.weighted.reset(initial.at(-1)!);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
useIntervalFn(() => {
|
||||
totalStat.value = hostContainers.value.reduce(
|
||||
(acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
totalCPU: acc.totalCPU + container.stat.cpu / cores,
|
||||
totalMem: acc.totalMem + container.stat.memory,
|
||||
totalMemUsage: acc.totalMemUsage + container.stat.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ totalCPU: 0, totalMem: 0, totalMemUsage: 0 },
|
||||
);
|
||||
}, 1000);
|
||||
</script>
|
||||
@@ -1,11 +1,120 @@
|
||||
<template>
|
||||
<ul class="grid gap-4 md:grid-cols-[repeat(auto-fill,minmax(480px,1fr))]">
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<HostCard :host="host" />
|
||||
<li v-for="host in hosts" class="card bg-base-100">
|
||||
<div class="card-body grid auto-cols-auto grid-flow-col justify-between gap-4">
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="flex items-center gap-1 truncate text-xl font-semibold">
|
||||
<HostIcon :type="host.type" class="flex-none" />
|
||||
<div class="truncate">
|
||||
{{ host.name }}
|
||||
</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-2 p-2"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
>
|
||||
{{ host.agentVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-row gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1"><ph:cpu /> {{ host.nCPU }} <span class="max-md:hidden">CPUs</span></li>
|
||||
<li class="flex items-center gap-1">
|
||||
<ph:memory /> {{ formatBytes(host.memTotal) }}
|
||||
<span class="max-md:hidden">total</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex flex-row flex-wrap gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1">
|
||||
<octicon:container-24 class="inline-block" />
|
||||
{{ $t("label.container", hostContainers[host.id]?.length ?? 0) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-4 md:gap-8" v-if="weightedStats[host.id]">
|
||||
<div
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalCPU / (host.nCPU * 100)) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ weightedStats[host.id].weighted.totalCPU.toFixed(0) }}%
|
||||
</div>
|
||||
<div
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalMem / host.memTotal) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ formatBytes(weightedStats[host.id].weighted.totalMem, { decimals: 1, short: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((container) => container.state === "running"));
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const hostContainers = computed(() => {
|
||||
const results: Record<string, Container[]> = {};
|
||||
for (const container of runningContainers.value) {
|
||||
if (!results[container.host]) {
|
||||
results[container.host] = [];
|
||||
}
|
||||
results[container.host].push(container);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
};
|
||||
const weightedStats: Record<string, { mostRecent: TotalStat; weighted: TotalStat }> = {};
|
||||
const initWeightedStats = () => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const mostRecent = ref<TotalStat>({ totalCPU: 0, totalMem: 0 });
|
||||
for (const container of containers) {
|
||||
mostRecent.value.totalCPU += container.stat.cpu;
|
||||
mostRecent.value.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
weightedStats[host] = reactive({ mostRecent, weighted: useExponentialMovingAverage(mostRecent) });
|
||||
}
|
||||
};
|
||||
|
||||
watchOnce(hostContainers, initWeightedStats);
|
||||
initWeightedStats();
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const stat = { totalCPU: 0, totalMem: 0 };
|
||||
for (const container of containers) {
|
||||
stat.totalCPU += container.stat.cpu;
|
||||
stat.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
if (weightedStats[host]) {
|
||||
// TODO fix this init
|
||||
weightedStats[host].mostRecent = stat;
|
||||
}
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
:title="$t('tooltip.merge-hosts')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ hosts[sessionHost].name }}
|
||||
@@ -24,21 +24,15 @@
|
||||
<div class="flex-none">
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
|
||||
<ion:ellipsis-vertical />
|
||||
<ph:dots-three-vertical-bold />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a class="text-sm capitalize" @click="toggleShowAllContainers()">
|
||||
<mdi:check class="w-4" v-if="showAllContainers" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("label.show-all-containers") }}
|
||||
</a>
|
||||
<a class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:collapse-all class="w-4" />
|
||||
{{ $t("label.collapse-all") }}
|
||||
|
||||
Show all containers
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -64,7 +58,7 @@
|
||||
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<component :is="icon" />
|
||||
{{ label.startsWith("label.") ? $t(label) : label }} ({{ containers.length }})
|
||||
{{ label.startsWith("label.") ? $t(label) : label }}
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
@@ -73,7 +67,7 @@
|
||||
}"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
:title="$t('tooltip.merge-containers')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
@@ -121,7 +115,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
import { showAllContainers, groupContainers } from "@/stores/settings";
|
||||
import { showAllContainers } from "@/stores/settings";
|
||||
|
||||
// @ts-ignore
|
||||
import Pin from "~icons/ph/map-pin-simple";
|
||||
@@ -147,18 +141,6 @@ const updateCollapsedGroups = (event: Event, label: string) => {
|
||||
} else {
|
||||
collapsedGroups.value.add(label);
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
menuItems.value.forEach(({ label }) => {
|
||||
collapsedGroups.value.add(label);
|
||||
});
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedPinnedContainers = debouncedRef(pinnedContainers, 200);
|
||||
@@ -183,7 +165,7 @@ const menuItems = computed(() => {
|
||||
|
||||
for (const item of sortedContainers.value) {
|
||||
const namespace = item.namespace;
|
||||
if (debouncedPinnedContainers.value?.has(item.name)) {
|
||||
if (debouncedPinnedContainers.value.has(item.name)) {
|
||||
pinned.push(item);
|
||||
} else if (namespace) {
|
||||
namespaced[namespace] ||= [];
|
||||
@@ -198,15 +180,10 @@ const menuItems = computed(() => {
|
||||
items.push({ label: "label.pinned", containers: pinned, icon: Pin });
|
||||
}
|
||||
for (const [label, containers] of Object.entries(namespaced).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const shouldGroup =
|
||||
groupContainers.value === "always" || (groupContainers.value === "at-least-2" && containers.length > 1);
|
||||
|
||||
if (shouldGroup) {
|
||||
if (containers.length > 1) {
|
||||
items.push({ label, containers, icon: Stack });
|
||||
} else {
|
||||
for (const container of containers) {
|
||||
singular.push(container);
|
||||
}
|
||||
singular.push(containers[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div ref="root" class="flex min-h-[1px] justify-center">
|
||||
<span class="loading loading-bars loading-md text-primary mt-4" v-show="isLoading"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { onLoadMore = () => Promise.resolve(), enabled } = defineProps<{
|
||||
onLoadMore: () => Promise<void>;
|
||||
enabled: boolean;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
const observer = new IntersectionObserver(async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (onLoadMore && enabled) {
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await onLoadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => observer.observe(root.value!));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div class="breadcrumbs flex-1">
|
||||
<ul>
|
||||
<li>
|
||||
<a @click.prevent="setNamespace(null)" class="link-primary">{{ $t("label.namespaces") }}</a>
|
||||
</li>
|
||||
<li v-if="selectedNamespace === 'all'">
|
||||
{{ $t("label.all-namespaces") }}
|
||||
</li>
|
||||
<li v-else-if="selectedNamespace" class="cursor-default">
|
||||
<router-link
|
||||
:to="{
|
||||
name: '/namespace/[name]',
|
||||
params: { name: selectedNamespace },
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ selectedNamespace }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
|
||||
<ph:dots-three-vertical-bold />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<a class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:collapse-all class="w-4" />
|
||||
{{ $t("label.collapse-all") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SlideTransition :slide-right="selectedNamespace !== null">
|
||||
<template #left>
|
||||
<ul class="menu p-0">
|
||||
<li>
|
||||
<a @click.prevent="setNamespace('all')">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.all-namespaces") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-for="ns in namespaces" :key="ns.name">
|
||||
<a @click.prevent="setNamespace(ns.name)">
|
||||
<ph:circles-four />
|
||||
{{ ns.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
<ul class="menu w-full p-0 text-[0.95rem]" ref="menu">
|
||||
<li v-for="{ name, owners } in filteredNamespaces" :key="name">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:stack />
|
||||
{{ name }} ({{ owners.length }})
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/namespace/[name]', params: { name } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</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">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li v-if="ownersWithoutNamespace.length > 0">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:circles-four />
|
||||
{{ $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">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</SlideTransition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const store = useK8sStore();
|
||||
|
||||
const { namespaces, owners } = storeToRefs(store);
|
||||
|
||||
const selectedNamespace = ref<string | null>("all");
|
||||
|
||||
const setNamespace = (namespace: string | null) => (selectedNamespace.value = namespace);
|
||||
|
||||
const filteredNamespaces = computed(() => {
|
||||
if (selectedNamespace.value === null || selectedNamespace.value === "all") {
|
||||
return namespaces.value;
|
||||
}
|
||||
return namespaces.value.filter((ns) => ns.name === selectedNamespace.value);
|
||||
});
|
||||
|
||||
const ownersWithoutNamespace = computed(() => {
|
||||
const filtered = owners.value.filter((owner) => !owner.namespace);
|
||||
if (selectedNamespace.value === null || selectedNamespace.value === "all") {
|
||||
return filtered;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const menu = useTemplateRef("menu");
|
||||
|
||||
const collapseAll = () => {
|
||||
const details = menu.value?.querySelectorAll("details");
|
||||
details?.forEach((detail) => (detail.open = false));
|
||||
};
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="namespace.name">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
|
||||
<ph:stack />
|
||||
<div class="font-mono text-sm font-semibold">{{ namespace.name }}</div>
|
||||
<ContainerDropdown :containers="namespace.containers">
|
||||
{{ $t("label.container", namespace.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="namespace.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useNamespaceStream"
|
||||
:entity="namespace"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { K8sNamespace } from "@/stores/k8s";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { namespace, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
namespace: K8sNamespace;
|
||||
}>();
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => namespace.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="owner.name">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
|
||||
<ph:stack-simple />
|
||||
<div class="font-mono text-sm font-semibold">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
<ContainerDropdown :containers="owner.containers">
|
||||
{{ $t("label.container", owner.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="owner.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useOwnerStream"
|
||||
:entity="owner"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { K8sOwner } from "@/stores/k8s";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { owner, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
owner: K8sOwner;
|
||||
}>();
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => owner.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
+12
-26
@@ -14,31 +14,23 @@
|
||||
|
||||
<dropdown class="dropdown-end" v-if="config.user">
|
||||
<template #trigger>
|
||||
<template v-if="config.disableAvatars || !config.user.email">
|
||||
<material-symbols:person class="size-6" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
|
||||
:src="withBase('/api/profile/avatar')"
|
||||
/>
|
||||
</template>
|
||||
<img
|
||||
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
|
||||
:src="withBase('/api/profile/avatar')"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<div class="font-bold">
|
||||
{{ config.user.name }}
|
||||
</div>
|
||||
<div v-if="config.user.email" class="text-sm font-light">
|
||||
<div class="text-sm font-light">
|
||||
{{ config.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="config.authProvider === 'simple' || config.logoutUrl" class="menu mt-4 p-0">
|
||||
<li>
|
||||
<button @click.prevent="logout()" class="text-primary p-2">
|
||||
<material-symbols:logout />
|
||||
{{ $t("button.logout") }}
|
||||
</button>
|
||||
<ul class="menu mt-4 p-0">
|
||||
<li v-if="config.authProvider === 'simple'">
|
||||
<button @click.prevent="logout()" class="text-primary p-2">{{ $t("button.logout") }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -46,17 +38,11 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const { logoutUrl } = config;
|
||||
|
||||
async function logout() {
|
||||
if (logoutUrl) {
|
||||
location.href = logoutUrl;
|
||||
} else {
|
||||
await fetch(withBase("/api/token"), {
|
||||
method: "DELETE",
|
||||
});
|
||||
await fetch(withBase("/api/token"), {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
location.reload();
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
<template>
|
||||
<DefineTemplate v-slot="{ data }">
|
||||
<ul class="inline-flex flex-wrap space-x-4" @click="preventDefaultOnLinks">
|
||||
<li v-for="(value, name) in data" :key="name" v-if="isObject(data)">
|
||||
<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>
|
||||
<LogItem :logEntry @click="showDrawer(LogDetails, { entry: logEntry })" class="clickable">
|
||||
<ul class="space-x-4">
|
||||
<li v-for="(value, name) in validValues" :key="name" class="inline-flex">
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null"><null></span>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<span class="font-bold" v-html="JSON.stringify(value)"> </span>
|
||||
</template>
|
||||
<span class="font-bold" v-html="stripAnsi(value.toString())" v-else></span>
|
||||
</li>
|
||||
<li v-else-if="Array.isArray(data)">
|
||||
<ul class="array inline-flex flex-wrap space-x-1">
|
||||
<li
|
||||
v-for="(item, index) in data"
|
||||
:key="index"
|
||||
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>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="key" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
</DefineTemplate>
|
||||
<LogItem :logEntry>
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" />
|
||||
<div @click="containers.length > 0 && showDrawer(LogDetails, { entry: logEntry })" class="cursor-pointer">
|
||||
<ReuseTemplate :data="validValues" />
|
||||
</div>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
@@ -39,37 +22,16 @@ const { logEntry } = defineProps<{
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
|
||||
|
||||
const validValues = computed(() => {
|
||||
return Object.fromEntries(Object.entries(logEntry.message).filter(([_, value]) => value !== undefined));
|
||||
});
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
function preventDefaultOnLinks(event: MouseEvent) {
|
||||
if (event.target instanceof HTMLAnchorElement && event.target.rel?.includes("external")) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
.key {
|
||||
@apply text-base-content/70 font-light;
|
||||
}
|
||||
|
||||
.value {
|
||||
@apply text-base-content font-bold;
|
||||
}
|
||||
|
||||
.array {
|
||||
@apply before:text-base-content/80 after:text-base-content/80 before:content-['['] after:content-[']'];
|
||||
}
|
||||
|
||||
.string {
|
||||
@apply before:content-['"'] after:content-['"'];
|
||||
.text-light {
|
||||
@apply text-base-content/70;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,15 +29,10 @@ describe("<ContainerEventSource />", () => {
|
||||
global.EventSource = EventSource;
|
||||
// @ts-ignore
|
||||
window.scrollTo = vi.fn();
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
root = null;
|
||||
rootMargin = "";
|
||||
thresholds = [];
|
||||
} as any;
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1560336942459);
|
||||
});
|
||||
@@ -70,29 +65,12 @@ describe("<ContainerEventSource />", () => {
|
||||
template: "Test from createLogEventSource",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "/container/[id].time.[datetime]",
|
||||
path: "/container/:id/time/:datetime",
|
||||
component: {
|
||||
template: "Test from createLogEventSource",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return mount(Component, {
|
||||
global: {
|
||||
plugins: [
|
||||
router,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
container: { containers: [{ id: "abc", image: "test:v123", host: "localhost" }] },
|
||||
},
|
||||
}),
|
||||
createI18n({}),
|
||||
],
|
||||
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
|
||||
components: {
|
||||
LogViewer,
|
||||
},
|
||||
@@ -106,7 +84,6 @@ describe("<ContainerEventSource />", () => {
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
hasComplexLogs: ref(false),
|
||||
levels: new Set<Level>(["info"]),
|
||||
historical: ref(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -161,7 +138,7 @@ describe("<ContainerEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
@@ -177,39 +154,39 @@ describe("<ContainerEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render dates with 12 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "rm": "foo bar", "c": "abc"}`,
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "c": "abc"}`,
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
|
||||
<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>
|
||||
@@ -6,26 +7,21 @@
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ul>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">
|
||||
{{ $t("label.no-logs") }}
|
||||
</div>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">Container has no logs yet</div>
|
||||
<slot :messages="messages" v-else></slot>
|
||||
<IndeterminateBar :color v-if="!historical" />
|
||||
<IndeterminateBar :color />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="T">
|
||||
import { LogStreamSource } from "@/composable/eventStreams";
|
||||
const route = useRoute();
|
||||
|
||||
const { entity, streamSource } = $defineProps<{
|
||||
streamSource: (t: Ref<T>) => LogStreamSource;
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
const { historical } = useLoggingContext();
|
||||
|
||||
const { messages, opened, loading, error } = streamSource(toRef(() => entity));
|
||||
|
||||
const { messages, loadOlderLogs, isLoadingMore, opened, loading, error, eventSourceURL } = streamSource($$(entity));
|
||||
const { loadingMore } = useLoggingContext();
|
||||
const color = computed(() => {
|
||||
if (error.value) return "error";
|
||||
if (loading.value) return "secondary";
|
||||
@@ -41,36 +37,33 @@ defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
if (historical.value && route.query.logId) {
|
||||
watchOnce(messages, async () => {
|
||||
await nextTick();
|
||||
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
});
|
||||
}
|
||||
const fetchMore = async () => {
|
||||
if (!isLoadingMore.value) {
|
||||
loadingMore.value = true;
|
||||
await loadOlderLogs();
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sizes = ref<string[]>([]);
|
||||
watch(
|
||||
opened,
|
||||
(value) => {
|
||||
if (value) return;
|
||||
const sizeOptions = [
|
||||
"w-2/12",
|
||||
"w-3/12",
|
||||
"w-4/12",
|
||||
"w-5/12",
|
||||
"w-6/12",
|
||||
"w-7/12",
|
||||
"w-8/12",
|
||||
"w-9/12",
|
||||
"w-10/12",
|
||||
"w-11/12",
|
||||
"w-full",
|
||||
];
|
||||
sizes.value = Array.from({ length: 18 }, () => sizeOptions[Math.floor(Math.random() * sizeOptions.length)]);
|
||||
},
|
||||
{
|
||||
flush: "sync",
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
const sizes = computedWithControl(eventSourceURL, () => {
|
||||
const sizeOptions = [
|
||||
"w-2/12",
|
||||
"w-3/12",
|
||||
"w-4/12",
|
||||
"w-5/12",
|
||||
"w-6/12",
|
||||
"w-7/12",
|
||||
"w-8/12",
|
||||
"w-9/12",
|
||||
"w-10/12",
|
||||
"w-11/12",
|
||||
"w-full",
|
||||
];
|
||||
const result = [];
|
||||
const iterations = 18;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
result.push(sizeOptions[Math.floor(Math.random() * sizeOptions.length)]);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<LogItem :logEntry>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="(msg, index) in logEntry.message" :key="index" class="flex items-start gap-x-2">
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" :position="getPosition(index)" />
|
||||
<div
|
||||
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre"
|
||||
v-html="colorize(msg)"
|
||||
:class="{ 'min-h-4': msg === '' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</LogItem>
|
||||
</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;
|
||||
}>();
|
||||
|
||||
const getPosition = (index: number): Position => {
|
||||
const len = logEntry.message.length;
|
||||
if (index === 0) return "start";
|
||||
if (index === len - 1) return "end";
|
||||
return "middle";
|
||||
};
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<div ref="root" class="flex min-h-[1px] flex-1 content-center justify-center">
|
||||
<span class="loading loading-bars loading-md text-primary m-2" v-show="isLoading"></span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LoadMoreLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: LoadMoreLogEntry;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
useIntersectionObserver(root, async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (isLoading.value) return;
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await logEntry.loadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
if (logEntry.rememberScrollPosition) {
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="dropdown dropdown-hover absolute -left-2 z-10 font-sans"
|
||||
:class="shouldShowBelow ? 'dropdown-right' : 'dropdown-right dropdown-end'"
|
||||
v-show="container"
|
||||
ref="dropdownRef"
|
||||
@mouseenter="checkDropdownPosition"
|
||||
>
|
||||
<router-link
|
||||
v-if="isSearching"
|
||||
@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"
|
||||
:to="{
|
||||
name: '/container/[id].time.[datetime]',
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
query: { logId: logEntry.id },
|
||||
}"
|
||||
>
|
||||
<material-symbols:eye-tracking />
|
||||
</router-link>
|
||||
<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"
|
||||
v-else
|
||||
>
|
||||
<ion:ellipsis-vertical />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="isSearching">
|
||||
<router-link
|
||||
@click="resetSearch()"
|
||||
:to="{
|
||||
name: '/container/[id].time.[datetime]',
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
query: { logId: logEntry.id },
|
||||
}"
|
||||
>
|
||||
<material-symbols:eye-tracking />
|
||||
{{ $t("action.see-in-context") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="copyLogMessage()"
|
||||
:disabled="!isSupported"
|
||||
:title="!isSupported ? $t('error.copy-not-supported') : ''"
|
||||
:class="{ 'cursor-not-allowed opacity-50': !isSupported }"
|
||||
>
|
||||
<material-symbols:content-copy />
|
||||
{{ $t("action.copy-log") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="copyPermalink()"
|
||||
:disabled="!isSupported"
|
||||
:title="!isSupported ? $t('error.copy-not-supported') : ''"
|
||||
:class="{ 'cursor-not-allowed opacity-50': !isSupported }"
|
||||
>
|
||||
<material-symbols:link />
|
||||
{{ $t("action.copy-link") }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li v-if="logEntry instanceof ComplexLogEntry">
|
||||
<a @click="showDrawer(LogDetails, { entry: logEntry })">
|
||||
<material-symbols:code-blocks-rounded />
|
||||
{{ $t("action.show-details") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { Container } from "@/models/Container";
|
||||
import { LogEntry, SimpleLogEntry, ComplexLogEntry, GroupedLogEntry, JSONObject } from "@/models/LogEntry";
|
||||
import LogDetails from "./LogDetails.vue";
|
||||
|
||||
const { logEntry, container } = defineProps<{
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
container: Container;
|
||||
}>();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
const { isSearching, resetSearch } = useSearchFilter();
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessage() {
|
||||
if (!isSupported.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logEntry instanceof ComplexLogEntry) {
|
||||
await copy(stripAnsi(logEntry.rawMessage));
|
||||
} else if (logEntry instanceof SimpleLogEntry) {
|
||||
await copy(stripAnsi(logEntry.rawMessage));
|
||||
} else if (logEntry instanceof GroupedLogEntry) {
|
||||
await copy(stripAnsi(logEntry.message.join("\n")));
|
||||
}
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPermalink() {
|
||||
if (!isSupported.value) {
|
||||
return;
|
||||
}
|
||||
const url = router.resolve({
|
||||
name: "/container/[id].time.[datetime]",
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
query: { logId: logEntry.id },
|
||||
}).href;
|
||||
|
||||
const resolved = new URL(url, window.location.origin);
|
||||
|
||||
await copy(resolved.href);
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hideMenu(e: MouseEvent) {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownRef = useTemplateRef<HTMLDivElement>("dropdownRef");
|
||||
const shouldShowBelow = ref(false);
|
||||
|
||||
function checkDropdownPosition() {
|
||||
if (!dropdownRef.value) return;
|
||||
|
||||
const rect = dropdownRef.value.getBoundingClientRect();
|
||||
shouldShowBelow.value = rect.top < 150;
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<aside>
|
||||
<header class="flex items-center gap-4">
|
||||
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
|
||||
<h2 class="text-sm"><RelativeTime :date="container.created" /></h2>
|
||||
<h2 class="text-sm"><DistanceTime :date="container.created" /></h2>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Tag class="items-start!">
|
||||
<Tag size="small" class="items-start!">
|
||||
<DateTime :date="date" class="text-blue whitespace-nowrap" />
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<header class="flex items-center gap-4">
|
||||
<Tag :data-level="entry.level" class="show-unknown text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
|
||||
<Tag :data-level="entry.level" class="text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
|
||||
<h1 class="text-lg max-md:hidden">
|
||||
<DateTime :date="entry.date" />
|
||||
</h1>
|
||||
<h2 class="text-sm"><RelativeTime :date="entry.date" /> on {{ entry.std }}</h2>
|
||||
<h2 class="text-sm"><DistanceTime :date="entry.date" /> on {{ entry.std }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-10">
|
||||
@@ -29,15 +29,15 @@
|
||||
<div class="flex gap-2">
|
||||
Raw JSON
|
||||
|
||||
<UseClipboard v-slot="{ copy, copied }" :source="entry.rawMessage">
|
||||
<UseClipboard v-slot="{ copy, copied }" :source="JSON.stringify(entry.unfilteredMessage)">
|
||||
<button class="swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
|
||||
<mdi:check class="swap-on" />
|
||||
<material-symbols:content-copy class="swap-off" />
|
||||
<mdi:content-copy class="swap-off" />
|
||||
</button>
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
|
||||
<pre v-html="syntaxHighlight(entry.unfilteredMessage)"></pre>
|
||||
</div>
|
||||
</section>
|
||||
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
|
||||
@@ -59,7 +59,7 @@
|
||||
{{ key.join(".") }}
|
||||
</td>
|
||||
<td class="truncate max-md:hidden">
|
||||
<code>{{ JSON.stringify(value) }}</code>
|
||||
<code v-html="JSON.stringify(value)"></code>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-primary" :checked="enabled" @change="toggleField(key)" />
|
||||
@@ -96,15 +96,14 @@ function toggleField(key: string[]) {
|
||||
const fields = computed({
|
||||
get() {
|
||||
const fieldsWithValue: { key: string[]; value: any; enabled: boolean }[] = [];
|
||||
const rawFields = JSON.parse(entry.rawMessage);
|
||||
const allFields = flattenJSONToMap(rawFields);
|
||||
const allFields = flattenJSONToMap(entry.unfilteredMessage);
|
||||
if (visibleKeys.value.size === 0) {
|
||||
for (const [key, value] of allFields) {
|
||||
fieldsWithValue.push({ key, value, enabled: true });
|
||||
}
|
||||
} else {
|
||||
for (const [key, enabled] of visibleKeys.value) {
|
||||
const value = getDeep(rawFields, key);
|
||||
const value = getDeep(entry.unfilteredMessage, key);
|
||||
fieldsWithValue.push({ key, value, enabled });
|
||||
}
|
||||
|
||||
@@ -142,8 +141,8 @@ const toggleAllFields = computed({
|
||||
},
|
||||
});
|
||||
|
||||
function syntaxHighlight(json: string) {
|
||||
json = JSON.stringify(JSON.parse(json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")), null, 2);
|
||||
function syntaxHighlight(json: any) {
|
||||
json = JSON.stringify(json, null, 2);
|
||||
return json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
|
||||
function (match: string) {
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
<template>
|
||||
<div class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<LogActions :logEntry :container />
|
||||
|
||||
<LogStd :std="logEntry.std" class="shrink-0 select-none" v-if="showStd" />
|
||||
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<RandomColorTag class="w-30 shrink-0 select-none md:w-40" :value="host.name" v-if="showHostname" />
|
||||
<RandomColorTag
|
||||
v-if="showContainerName"
|
||||
class="w-30 shrink-0 select-none group-[.compact]:flex-1 md:w-40"
|
||||
:value="container.name"
|
||||
v-if="showContainerName"
|
||||
truncateRight
|
||||
/>
|
||||
<LogDate
|
||||
v-if="showTimestamp"
|
||||
:date="logEntry.date"
|
||||
class="shrink-0 select-none"
|
||||
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
|
||||
/>
|
||||
<LogDate :date="logEntry.date" v-if="showTimestamp" class="shrink-0 select-none" />
|
||||
</div>
|
||||
<LogLevel
|
||||
class="flex select-none"
|
||||
:level="logEntry.level"
|
||||
:position="logEntry instanceof SimpleLogEntry ? logEntry.position : undefined"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry } from "@/models/LogEntry";
|
||||
import { LogEntry, SimpleLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: LogEntry<any>;
|
||||
@@ -35,6 +33,4 @@ const { hosts } = useHosts();
|
||||
|
||||
const container = currentContainer(toRef(() => logEntry.containerID));
|
||||
const host = computed(() => hosts.value[container.value.host]);
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
:data-level="level"
|
||||
:data-position="position"
|
||||
class="mt-1.5 size-2.5 flex-none rounded-lg"
|
||||
:class="{ showUnknown }"
|
||||
></div>
|
||||
<div :data-level="level" :data-position="position" class="mt-1.5 size-2.5 flex-none rounded-lg"></div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Position, Level } from "@/models/LogEntry";
|
||||
import { Position } from "@/models/LogEntry";
|
||||
|
||||
const {
|
||||
level,
|
||||
position,
|
||||
showUnknown = false,
|
||||
} = defineProps<{
|
||||
level?: Level;
|
||||
defineProps<{
|
||||
level?: string;
|
||||
position?: Position;
|
||||
showUnknown?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
[data-position="start"],
|
||||
[data-position="middle"],
|
||||
[data-position="end"] {
|
||||
align-self: stretch;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[data-position="start"] {
|
||||
border-radius: 0.375rem 0.375rem 0 0;
|
||||
border-radius: 0.5em 0.5em 0 0;
|
||||
height: 70%;
|
||||
margin-bottom: -0.4em;
|
||||
margin-top: auto;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
[data-position="middle"] {
|
||||
border-radius: 0;
|
||||
margin-top: 0;
|
||||
height: auto;
|
||||
margin: -0.4em 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-position="end"] {
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
margin-top: 0;
|
||||
border-radius: 0 0 0.5em 0.5em;
|
||||
height: 70%;
|
||||
margin-top: -0.4em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@@ -62,8 +53,4 @@ const {
|
||||
[data-level="warn"] {
|
||||
@apply !bg-orange;
|
||||
}
|
||||
|
||||
[data-level="unknown"].show-unknown {
|
||||
@apply !bg-base-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<ul class="group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }" data-logs>
|
||||
<ul class="events group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
|
||||
<li
|
||||
v-for="item in messages"
|
||||
ref="list"
|
||||
:key="item.id"
|
||||
:id="item.id.toString()"
|
||||
:data-key="item.id"
|
||||
:data-time="item.date.getTime()"
|
||||
class="group/entry"
|
||||
>
|
||||
@@ -16,17 +16,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { progress, currentDate } = useScrollContext();
|
||||
const { loading, progress, currentDate } = useScrollContext();
|
||||
|
||||
const { messages } = defineProps<{
|
||||
messages: LogEntry<string | string[] | JSONObject>[];
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
}>();
|
||||
|
||||
watchEffect(() => {
|
||||
loading.value = messages.length === 0;
|
||||
});
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const list = ref<HTMLElement[]>([]);
|
||||
|
||||
let previousDate = new Date();
|
||||
useIntersectionObserver(
|
||||
list,
|
||||
(entries) => {
|
||||
@@ -37,12 +40,9 @@ useIntersectionObserver(
|
||||
const time = entry.target.getAttribute("data-time");
|
||||
if (time) {
|
||||
const date = new Date(parseInt(time));
|
||||
if (+date === +previousDate) break;
|
||||
previousDate = date;
|
||||
const diff = new Date().getTime() - container.created.getTime();
|
||||
progress.value = (date.getTime() - container.created.getTime()) / diff;
|
||||
currentDate.value = date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ useIntersectionObserver(
|
||||
</script>
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
ul {
|
||||
.events {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
@@ -67,7 +67,7 @@ ul {
|
||||
monospace;
|
||||
|
||||
> li {
|
||||
@apply flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] md:px-4;
|
||||
@apply has-[.clickable]:hover:bg-primary/10 flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] has-[.clickable]:cursor-pointer md:px-4;
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ ul {
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply text-[1em];
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
@@ -99,10 +99,6 @@ ul {
|
||||
@apply bg-secondary inline-block rounded-xs;
|
||||
animation: pops 200ms ease-out;
|
||||
}
|
||||
|
||||
:deep(a[rel~="external"]) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
|
||||
v-if="isSupported"
|
||||
:title="t('log_actions.copy_log')"
|
||||
>
|
||||
<span
|
||||
class="text-primary rounded-sm bg-slate-800/60 px-1.5 py-1 hover:bg-slate-700"
|
||||
@click.prevent="copyLogMessageToClipBoard()"
|
||||
>
|
||||
<carbon:copy-file />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry, JSONObject } from "@/models/LogEntry";
|
||||
|
||||
const { message } = defineProps<{
|
||||
message: () => string;
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
}>();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessageToClipBoard() {
|
||||
await copy(message());
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Tag :std="std" class="items-start!">
|
||||
<Tag size="small" :std="std" class="items-start!">
|
||||
{{ std }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
@@ -6,13 +6,37 @@
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | string[] | JSONObject>[];
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
visibleKeys: Map<string[], boolean>;
|
||||
}>();
|
||||
|
||||
const { messages, visibleKeys } = toRefs(props);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const visibleMessages = filteredPayload(messages);
|
||||
const router = useRouter();
|
||||
|
||||
watchEffect(() => {
|
||||
const query = {} as Record<string, string>;
|
||||
if (debouncedSearchFilter.value !== "") {
|
||||
query.search = debouncedSearchFilter.value;
|
||||
}
|
||||
|
||||
if (!streamConfig.value.stderr) {
|
||||
query.stderr = streamConfig.value.stderr.toString();
|
||||
}
|
||||
|
||||
if (!streamConfig.value.stdout) {
|
||||
query.stdout = streamConfig.value.stdout.toString();
|
||||
}
|
||||
|
||||
router.push({
|
||||
query,
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm gap-0.5 px-2">
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a @click="clear()">
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="enableDownload">
|
||||
<a :href="downloadUrl" download>
|
||||
<octicon:download-24 />
|
||||
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
|
||||
</a>
|
||||
<li>
|
||||
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="showSearch = true">
|
||||
<a @click.prevent="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
<KeyShortcut char="f" />
|
||||
</a>
|
||||
@@ -91,22 +84,22 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { enableDownload } = config;
|
||||
|
||||
const clear = defineEmit();
|
||||
|
||||
const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
|
||||
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();
|
||||
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels);
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
|
||||
);
|
||||
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/containers/${containers.value.map((c) => c.host + "~" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
<template>
|
||||
<div class="flex gap-1 md:gap-4">
|
||||
<div class="grid min-w-15 grid-cols-[auto_1fr] items-center gap-0.5 text-xs leading-none max-md:hidden">
|
||||
<PhArrowUp class="text-primary" />
|
||||
<span class="tabular-nums">{{ formatBytes(networkRate.tx, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary" />
|
||||
<span class="tabular-nums">{{ formatBytes(networkRate.rx, { short: true, decimals: 1 }) }}/s</span>
|
||||
</div>
|
||||
<StatMonitor
|
||||
:data="cpuData"
|
||||
:icon="PhCpu"
|
||||
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
|
||||
:limit="roundCPU(limits.cpu) + ' CPU'"
|
||||
container-class="border-primary/40 bg-primary/20"
|
||||
text-class="hover:text-primary"
|
||||
bar-class="bg-primary"
|
||||
:formatter="(value: number) => value.toFixed(2) + '%'"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<StatMonitor
|
||||
:data="memoryData"
|
||||
:icon="PhMemory"
|
||||
label="mem"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
|
||||
container-class="border-secondary/40 bg-secondary/20"
|
||||
text-class="hover:text-secondary"
|
||||
bar-class="bg-secondary"
|
||||
:formatter="(value: number) => formatBytes(value)"
|
||||
/>
|
||||
<StatMonitor
|
||||
:data="cpuData"
|
||||
label="load"
|
||||
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
|
||||
:limit="roundCPU(limits.cpu) + ' CPU'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,145 +18,106 @@
|
||||
<script lang="ts" setup>
|
||||
import { Stat } from "@/models/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
const networkRate = ref({ rx: 0, tx: 0 });
|
||||
|
||||
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
}
|
||||
const hostInfo = hosts.value[container.host];
|
||||
return hostInfo?.nCPU ?? 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => containers,
|
||||
() => {
|
||||
const initial: Stat[] = [];
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = containers.reduce(
|
||||
(acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
(acc, { statsHistory }) => {
|
||||
const item = statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu / cores,
|
||||
cpu: acc.cpu + item.cpu,
|
||||
memory: acc.memory + item.memory,
|
||||
memoryUsage: acc.memoryUsage + item.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + item.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + item.networkTxTotal,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
);
|
||||
initial.push(stat);
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const limits = computed(() => {
|
||||
// Group containers by host
|
||||
const containersByHost = new Map<string, Container[]>();
|
||||
containers.forEach((container) => {
|
||||
if (!containersByHost.has(container.host)) {
|
||||
containersByHost.set(container.host, []);
|
||||
const hostLimits = new Map<string, { cpu: number; memory: number }>();
|
||||
|
||||
for (const container of containers) {
|
||||
if (!hostLimits.has(container.host)) {
|
||||
hostLimits.set(container.host, {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
});
|
||||
}
|
||||
containersByHost.get(container.host)!.push(container);
|
||||
});
|
||||
|
||||
let totalCpu = 0;
|
||||
let totalMemory = 0;
|
||||
|
||||
// Process each host independently
|
||||
containersByHost.forEach((hostContainers, hostId) => {
|
||||
const hostInfo = hosts.value[hostId];
|
||||
const hostTotalMemory = hostInfo?.memTotal || 0;
|
||||
const hostTotalCpu = hostInfo?.nCPU || 0;
|
||||
|
||||
// Check if any container lacks limits
|
||||
const hasUnlimitedCpu = hostContainers.some((c) => !c.cpuLimit || c.cpuLimit <= 0);
|
||||
const hasUnlimitedMemory = hostContainers.some((c) => !c.memoryLimit);
|
||||
|
||||
// Calculate CPU for this host
|
||||
if (hasUnlimitedCpu) {
|
||||
// At least one container has no limit, use host total
|
||||
totalCpu += hostTotalCpu;
|
||||
} else {
|
||||
// All containers have limits, sum them up (capped at host total)
|
||||
const sumCpu = hostContainers.reduce((sum, c) => sum + 1, 0);
|
||||
totalCpu += Math.min(sumCpu, hostTotalCpu);
|
||||
if (hostLimits.get(container.host)!.cpu < hosts.value[container.host].nCPU) {
|
||||
if (container.cpuLimit == 0) {
|
||||
hostLimits.get(container.host)!.cpu = hosts.value[container.host].nCPU;
|
||||
} else {
|
||||
hostLimits.get(container.host)!.cpu = hostLimits.get(container.host)!.cpu + container.cpuLimit;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Memory for this host
|
||||
if (hasUnlimitedMemory) {
|
||||
// At least one container has no limit, use host total
|
||||
totalMemory += hostTotalMemory;
|
||||
} else {
|
||||
// All containers have limits, sum them up (capped at host total)
|
||||
const sumMemory = hostContainers.reduce((sum, c) => sum + (c.memoryLimit || 0), 0);
|
||||
totalMemory += Math.min(sumMemory, hostTotalMemory);
|
||||
if (hostLimits.get(container.host)!.memory < hosts.value[container.host].memTotal) {
|
||||
if (container.memoryLimit == 0) {
|
||||
hostLimits.get(container.host)!.memory = hosts.value[container.host].memTotal;
|
||||
} else {
|
||||
hostLimits.get(container.host)!.memory = hostLimits.get(container.host)!.memory + container.memoryLimit;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cpu: totalCpu,
|
||||
memory: totalMemory,
|
||||
};
|
||||
return hostLimits.values().reduce(
|
||||
(acc, { cpu, memory }) => {
|
||||
return {
|
||||
cpu: acc.cpu + cpu,
|
||||
memory: acc.memory + memory,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
useIntervalFn(() => {
|
||||
const previousStat = totalStat.value;
|
||||
totalStat.value = containers.reduce(
|
||||
(acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
(acc, { stat }) => {
|
||||
return {
|
||||
cpu: acc.cpu + container.stat.cpu / cores,
|
||||
memory: acc.memory + container.stat.memory,
|
||||
memoryUsage: acc.memoryUsage + container.stat.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + container.stat.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + container.stat.networkTxTotal,
|
||||
cpu: acc.cpu + stat.cpu,
|
||||
memory: acc.memory + stat.memory,
|
||||
memoryUsage: acc.memoryUsage + stat.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
);
|
||||
|
||||
networkRate.value = {
|
||||
rx: Math.max(0, totalStat.value.networkRxTotal - previousStat.networkRxTotal),
|
||||
tx: Math.max(0, totalStat.value.networkTxTotal - previousStat.networkTxTotal),
|
||||
};
|
||||
}, 1000);
|
||||
|
||||
const cpuData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu).toFixed(2) + "%",
|
||||
})),
|
||||
);
|
||||
|
||||
const memoryData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.memory,
|
||||
value: stat.memoryUsage,
|
||||
y: stat.memoryUsage,
|
||||
value: formatBytes(stat.memoryUsage),
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<template>
|
||||
<LogItem :logEntry>
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" />
|
||||
<div
|
||||
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre"
|
||||
v-html="colorize(logEntry.message)"
|
||||
class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap"
|
||||
v-html="linkify(colorize(logEntry.message))"
|
||||
></div>
|
||||
<LogMessageActions
|
||||
class="absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100"
|
||||
:message="() => decodeXML(stripAnsi(logEntry.message))"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import { decodeXML } from "entities";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
@@ -22,4 +28,15 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
const urlPattern =
|
||||
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_+.~#?&/=]*/g;
|
||||
const linkify = (text: string) =>
|
||||
text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
.log-wrapper :deep(a) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<div class="my-4 flex-1 text-center">
|
||||
<div class="relative">
|
||||
<ZigZag class="absolute inset-0 mt-2" />
|
||||
<button class="btn btn-primary btn-xs relative whitespace-pre-wrap" @click="logEntry.loadSkippedEntries()">
|
||||
<span class="bg-base-200 relative px-4 py-2 font-bold whitespace-pre-wrap">
|
||||
{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SkippedLogsEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
defineProps<{
|
||||
logEntry: SkippedLogsEntry;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
<template>
|
||||
<div class="relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false" :class="textClass">
|
||||
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
|
||||
<BarChart
|
||||
:chart-data="chartData"
|
||||
:bar-class="`${barClass} opacity-70 hover:opacity-100`"
|
||||
class="h-8 w-44"
|
||||
@hover-index="(startIndex: number, endIndex: number) => onHoverIndexChange(startIndex, endIndex)"
|
||||
/>
|
||||
<div class="hover:text-secondary relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
|
||||
<div class="border-primary overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden">
|
||||
<StatSparkline :data="data" @selected-point="onSelectedPoint" />
|
||||
</div>
|
||||
<div class="bg-base-200 flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
|
||||
<component :is="icon" class="text-sm" />
|
||||
<div class="font-bold tabular-nums select-none">
|
||||
{{ displayValue }}
|
||||
<div class="bg-base-200 inline-flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
|
||||
<div class="font-light uppercase">{{ label }}</div>
|
||||
<div class="font-bold select-none">
|
||||
{{ mouseOver ? (selectedPoint?.value ?? selectedPoint?.y ?? statValue) : statValue }}
|
||||
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,48 +14,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
|
||||
const {
|
||||
data,
|
||||
icon,
|
||||
label,
|
||||
statValue,
|
||||
limit = -1,
|
||||
containerClass = "border-primary",
|
||||
textClass = "",
|
||||
barClass = "bg-primary",
|
||||
formatter,
|
||||
} = defineProps<{
|
||||
data: Point<unknown>[];
|
||||
icon: Component;
|
||||
label: string;
|
||||
statValue: string | number;
|
||||
limit?: string | number;
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatter?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => data.map((point) => (point.y as number) ?? 0));
|
||||
const selectedPoint = ref<Point<unknown> | undefined>();
|
||||
const onSelectedPoint = (point: Point<unknown>) => (selectedPoint.value = point);
|
||||
const mouseOver = ref(false);
|
||||
const hoveredRange = ref<{ start: number; end: number } | null>(null);
|
||||
|
||||
function onHoverIndexChange(startIndex: number, endIndex: number) {
|
||||
hoveredRange.value = { start: startIndex, end: endIndex };
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (mouseOver.value && hoveredRange.value !== null) {
|
||||
const { start, end } = hoveredRange.value;
|
||||
const points = data.slice(start, end + 1);
|
||||
const sum = points.reduce((acc, point) => acc + ((point.value as number) ?? (point.y as number) ?? 0), 0);
|
||||
const avg = sum / points.length;
|
||||
|
||||
if (formatter) {
|
||||
return formatter(avg);
|
||||
}
|
||||
return avg.toFixed(2);
|
||||
}
|
||||
return statValue;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<svg :width="width" :height="height" @mousemove="onMove" class="group">
|
||||
<path :d="path" class="fill-primary" />
|
||||
<line :x1="lineX" y1="0" :x2="lineX" :y2="height" class="stroke-secondary invisible stroke-2 group-hover:visible" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { extent } from "d3-array";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { area, curveStep } from "d3-shape";
|
||||
|
||||
const d3 = { extent, scaleLinear, area, curveStep };
|
||||
const { data, width = 175, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
|
||||
const x = d3.scaleLinear().range([0, width]);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const selectedPoint = defineEmit<[value: Point<unknown>]>();
|
||||
|
||||
const shape = d3
|
||||
.area<Point<unknown>>()
|
||||
.curve(d3.curveStep)
|
||||
.x((d) => x(d.x))
|
||||
.y0(height)
|
||||
.y1((d) => y(d.y));
|
||||
|
||||
const path = computed(() => {
|
||||
x.domain(d3.extent(data, (d) => d.x) as [number, number]);
|
||||
y.domain(d3.extent([...data, { y: 1 }], (d) => d.y) as [number, number]);
|
||||
|
||||
return shape(data) ?? "";
|
||||
});
|
||||
|
||||
let lineX = $ref(0);
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
const { offsetX } = e;
|
||||
const xValue = x.invert(offsetX);
|
||||
const index = Math.round(xValue);
|
||||
lineX = x(index);
|
||||
const point = data[index];
|
||||
selectedPoint(point);
|
||||
}
|
||||
</script>
|
||||
@@ -1,129 +1,79 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
|
||||
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
<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-->
|
||||
</ul>
|
||||
</div>
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<!--v-if-->
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none">
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
|
||||
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
<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-->
|
||||
</ul>
|
||||
</div>
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<!--v-if-->
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none">
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
|
||||
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
<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-->
|
||||
</ul>
|
||||
</div>
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<!--v-if-->
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none">
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre">This is a message.</div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > should parse messages 1`] = `
|
||||
LoadMoreLogEntry {
|
||||
"_message": "",
|
||||
"containerID": "",
|
||||
"date": 2019-06-12T10:55:42.709Z,
|
||||
"id": 1560336942709,
|
||||
SimpleLogEntry {
|
||||
"_message": "This is a message.",
|
||||
"containerID": undefined,
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"id": 1,
|
||||
"level": undefined,
|
||||
"loader": [Function],
|
||||
"rawMessage": "info",
|
||||
"rememberScrollPosition": true,
|
||||
"position": undefined,
|
||||
"std": "stderr",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-lg border p-2 md:p-3" :class="containerClass">
|
||||
<div class="mb-2 flex items-center gap-1.5 text-sm font-medium" :class="textClass">
|
||||
<component :is="icon" class="text-lg" />
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="mb-1.5 text-lg font-semibold tabular-nums">{{ formattedValue }}</div>
|
||||
<div class="text-base-content/60 mb-1 text-xs tabular-nums max-md:hidden">
|
||||
avg {{ formatValue(average) }} • pk {{ formatValue(peak) }}
|
||||
</div>
|
||||
<BarChart class="h-8" :chartData="percentData" :barClass="barClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
export interface MetricDataPoint {
|
||||
percent: number; // value 0 - 100
|
||||
value: number;
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
chartData,
|
||||
containerClass = "",
|
||||
textClass = "",
|
||||
barClass = "",
|
||||
formatValue = (v: number) => v.toString(),
|
||||
} = defineProps<{
|
||||
label: string;
|
||||
icon: Component;
|
||||
value: string | number;
|
||||
chartData: MetricDataPoint[];
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const percentData = computed(() => chartData.map((d) => d.percent));
|
||||
|
||||
const peak = computed(() => (chartData.length > 0 ? Math.max(...chartData.map((d) => d.value)) : 0));
|
||||
|
||||
const average = computed(() => {
|
||||
if (chartData.length === 0) return 0;
|
||||
return chartData.reduce((sum, d) => sum + d.value, 0) / chartData.length;
|
||||
});
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (typeof value === "string") return value;
|
||||
return formatValue(value);
|
||||
});
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="containers.length && ready">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-1 md:ml-4 md:gap-2">
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<octicon:container-24 />
|
||||
<ContainerDropdown :containers="containers">{{ $t("label.container", containers.length) }}</ContainerDropdown>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 px-4 py-4 md:px-8">
|
||||
<div class="flex flex-col gap-8 px-4 py-4 md:px-8">
|
||||
<section>
|
||||
<Links>
|
||||
<template #more-items>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="show && (delayedShow || globalShow)"
|
||||
class="ring-base-content/20 bg-base-100 fixed z-50 rounded-sm p-3 shadow-sm ring"
|
||||
class="border-base-content/20 bg-base-100 fixed z-50 rounded-sm border p-4 shadow-sm"
|
||||
ref="content"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<span> % </span>
|
||||
</div>
|
||||
</div>
|
||||
<RelativeTime :date="date" class="text-sm whitespace-nowrap" />
|
||||
<DistanceTime :date="date" class="text-sm whitespace-nowrap" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -93,7 +93,7 @@ svg {
|
||||
}
|
||||
|
||||
.fadeout-leave-active {
|
||||
@apply transition-opacity duration-400;
|
||||
@apply transition-opacity;
|
||||
}
|
||||
|
||||
.fadeout-leave-to {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-2 border-b py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div ref="scrollObserver" class="h-px"></div>
|
||||
</main>
|
||||
|
||||
<div class="mr-16 text-right" v-if="!historical">
|
||||
<div class="mr-16 text-right">
|
||||
<transition name="fade">
|
||||
<button
|
||||
class="btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm transition-colors"
|
||||
@@ -49,29 +49,28 @@ const scrollableContent = ref<HTMLElement>();
|
||||
|
||||
const scrollContext = provideScrollContext();
|
||||
|
||||
const { loadingMore, historical } = useLoggingContext();
|
||||
if (!historical.value) {
|
||||
useIntersectionObserver(scrollObserver, ([entry]) => (scrollContext.paused = entry.intersectionRatio == 0), {
|
||||
threshold: [0, 1],
|
||||
rootMargin: "40px 0px",
|
||||
});
|
||||
const { loadingMore } = useLoggingContext();
|
||||
|
||||
useMutationObserver(
|
||||
scrollableContent,
|
||||
(records) => {
|
||||
if (!scrollContext.paused) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const record = records[records.length - 1];
|
||||
const children = (record.target as HTMLElement).children;
|
||||
if (children[children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore.value = true;
|
||||
}
|
||||
useIntersectionObserver(scrollObserver, ([entry]) => (scrollContext.paused = entry.intersectionRatio == 0), {
|
||||
threshold: [0, 1],
|
||||
rootMargin: "40px 0px",
|
||||
});
|
||||
|
||||
useMutationObserver(
|
||||
scrollableContent,
|
||||
(records) => {
|
||||
if (!scrollContext.paused) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const record = records[records.length - 1];
|
||||
const children = (record.target as HTMLElement).children;
|
||||
if (children[children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore.value = true;
|
||||
}
|
||||
},
|
||||
{ childList: true, subtree: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ childList: true, subtree: true },
|
||||
);
|
||||
|
||||
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
scrollObserver.value?.scrollIntoView({ behavior });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div
|
||||
class="fixed z-50 flex w-full justify-end p-2"
|
||||
class="fixed z-10 flex w-full justify-end p-2"
|
||||
v-show="showSearch"
|
||||
v-if="search"
|
||||
ref="container"
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
<template>
|
||||
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 w-full flex-col">
|
||||
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 flex-col">
|
||||
<Carousel v-model="selectedCard" class="flex-1">
|
||||
<CarouselItem v-if="config.mode === 'k8s'" :title="$t('label.k8s-menu')" id="k8s">
|
||||
<K8sMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem v-if="config.mode === 'swarm' && services.length > 0" :title="$t('label.swarm-menu')" id="swarm">
|
||||
<SwarmMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.host-menu')" id="host">
|
||||
<HostMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.group-menu')" v-if="customGroups.length > 0" id="group">
|
||||
<GroupMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem v-if="config.mode !== 'swarm' && services.length > 0" :title="$t('label.swarm-menu')" id="swarm">
|
||||
<CarouselItem :title="$t('label.swarm-menu')" v-if="services.length > 0" id="swarm">
|
||||
<SwarmMenu />
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
@@ -30,25 +24,13 @@ const { ready } = storeToRefs(containerStore);
|
||||
const route = useRoute();
|
||||
const swarmStore = useSwarmStore();
|
||||
const { services, customGroups } = storeToRefs(swarmStore);
|
||||
|
||||
let defaultCard: "host" | "swarm" | "group" | "k8s";
|
||||
switch (config.mode) {
|
||||
case "k8s":
|
||||
defaultCard = "k8s";
|
||||
break;
|
||||
case "swarm":
|
||||
defaultCard = "swarm";
|
||||
break;
|
||||
default:
|
||||
defaultCard = "host";
|
||||
}
|
||||
const selectedCard = ref<"host" | "swarm" | "group" | "k8s">(defaultCard);
|
||||
const selectedCard = ref<"host" | "swarm" | "group">("host");
|
||||
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
if (route.meta.menu && ["host", "swarm", "group", "k8s"].includes(route.meta.menu as string)) {
|
||||
selectedCard.value = route.meta.menu as "host" | "swarm" | "group" | "k8s";
|
||||
if (route.meta.menu && ["host", "swarm", "group"].includes(route.meta.menu as string)) {
|
||||
selectedCard.value = route.meta.menu as "host" | "swarm" | "group";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<aside class="fixed flex h-screen w-[inherit] flex-col gap-4 p-3" data-testid="navigation">
|
||||
<h1>
|
||||
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-4 overflow-hidden text-4xl font-thin">
|
||||
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-4 overflow-hidden">
|
||||
<Logo class="h-14 w-14 shrink-0" />
|
||||
Dozzle
|
||||
<span class="bg-gradient-to-r from-[#FFE351] to-[#BF6800] bg-clip-text text-4xl font-thin text-transparent">
|
||||
Dozzle
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
<template>
|
||||
<div class="mb-2 flex items-center">
|
||||
<div class="flex-1">
|
||||
{{ $t("label.service", services.length) }}
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
|
||||
<ph:dots-three-vertical-bold />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<a class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:collapse-all class="w-4" />
|
||||
{{ $t("label.collapse-all") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="menu w-full p-0 text-[0.95rem]" ref="menu">
|
||||
<ul class="menu w-full p-0 text-[0.95rem]">
|
||||
<li v-for="{ name, services } in stacks" :key="name">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:stack />
|
||||
{{ name }} ({{ services.length }})
|
||||
{{ name }}
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/stack/[name]', params: { name } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
:title="$t('tooltip.merge-services')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
@@ -55,7 +32,7 @@
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.services") }} ({{ servicesWithoutStacks.length }})
|
||||
{{ $t("label.services") }}
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="service in servicesWithoutStacks" :key="service.name">
|
||||
@@ -78,11 +55,4 @@ const store = useSwarmStore();
|
||||
const { stacks, services } = storeToRefs(store);
|
||||
|
||||
const servicesWithoutStacks = computed(() => services.value.filter((service) => !service.stack));
|
||||
|
||||
const menu = useTemplateRef("menu");
|
||||
|
||||
const collapseAll = () => {
|
||||
const details = menu.value?.querySelectorAll("details");
|
||||
details?.forEach((detail) => (detail.open = false));
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<aside class="flex h-[calc(100svh-50px)] flex-col gap-2">
|
||||
<aside>
|
||||
<header class="flex items-center gap-4">
|
||||
<material-symbols:terminal class="size-8" />
|
||||
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
|
||||
<h2 class="text-sm">Started <RelativeTime :date="container.created" /></h2>
|
||||
<h2 class="text-sm">Started <DistanceTime :date="container.created" /></h2>
|
||||
</header>
|
||||
|
||||
<div ref="host" class="shell flex-1"></div>
|
||||
<div class="mt-8 flex flex-col gap-2">
|
||||
<section>
|
||||
<div ref="host" class="shell"></div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -17,72 +21,34 @@ const { container, action } = defineProps<{ container: Container; action: "attac
|
||||
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
const { WebLinksAddon } = await import("@xterm/addon-web-links");
|
||||
const { FitAddon } = await import("@xterm/addon-fit");
|
||||
|
||||
const host = useTemplateRef<HTMLDivElement>("host");
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
theme: {
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
});
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
function sendEvent(type: "userinput" | "resize", data?: string, width?: number, height?: number) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const event: { type: string; data?: string; width?: number; height?: number } = { type };
|
||||
if (data !== undefined) event.data = data;
|
||||
if (width !== undefined) event.width = width;
|
||||
if (height !== undefined) event.height = height;
|
||||
|
||||
ws.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
terminal.open(host.value!);
|
||||
fitAddon.fit();
|
||||
|
||||
terminal.resize(100, 40);
|
||||
ws = new WebSocket(withBase(`/api/hosts/${container.host}/containers/${container.id}/${action}`));
|
||||
ws.onopen = () => {
|
||||
terminal.writeln(`Attaching to ${container.name} 🚀`);
|
||||
|
||||
// Send initial resize event
|
||||
sendEvent("resize", undefined, terminal.cols, terminal.rows);
|
||||
|
||||
if (action === "attach") {
|
||||
sendEvent("userinput", "\r");
|
||||
ws?.send("\r");
|
||||
}
|
||||
|
||||
terminal.onData((data) => {
|
||||
sendEvent("userinput", data);
|
||||
ws?.send(data);
|
||||
});
|
||||
|
||||
// Handle terminal resize
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
sendEvent("resize", undefined, cols, rows);
|
||||
});
|
||||
|
||||
terminal.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => terminal.write(event.data);
|
||||
ws.addEventListener("close", () => {
|
||||
terminal.writeln("⚠️ Connection closed");
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
const { width, height } = useWindowSize();
|
||||
watch([width, height], () => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -96,7 +62,7 @@ onUnmounted(() => {
|
||||
|
||||
.shell {
|
||||
& :deep(.terminal) {
|
||||
@apply overflow-hidden rounded border p-2;
|
||||
@apply overflow-hidden rounded border-1 p-2;
|
||||
&:is(.focus) {
|
||||
@apply border-primary;
|
||||
}
|
||||
@@ -113,10 +79,6 @@ onUnmounted(() => {
|
||||
& :deep(.xterm-cursor-block.xterm-cursor-blink) {
|
||||
animation-name: blink !important;
|
||||
}
|
||||
|
||||
& :deep(.xterm-selection) {
|
||||
@apply bg-primary/30;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()" data-ci-skip>{{ text }}</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
|
||||
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
|
||||
|
||||
const {
|
||||
date,
|
||||
strict = false,
|
||||
suffix = true,
|
||||
} = defineProps<{
|
||||
date: Date;
|
||||
strict?: boolean;
|
||||
suffix?: boolean;
|
||||
}>();
|
||||
|
||||
const text = ref<string>();
|
||||
|
||||
watch($$(date), updateFromNow, { immediate: true });
|
||||
|
||||
function updateFromNow() {
|
||||
const fn = strict ? formatDistanceToNowStrict : formatDistanceToNow;
|
||||
text.value = fn(date, {
|
||||
addSuffix: suffix,
|
||||
});
|
||||
}
|
||||
useIntervalFn(updateFromNow, 30_000, { immediateCallback: true });
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<details class="dropdown dropdown-end" ref="details" v-on-click-outside="close">
|
||||
<details class="dropdown" ref="details" v-on-click-outside="close">
|
||||
<summary class="btn btn-primary flex-nowrap" v-bind="$attrs">
|
||||
<slot name="trigger"> {{ label }} <carbon:caret-down /></slot>
|
||||
</summary>
|
||||
<ul
|
||||
class="menu dropdown-content rounded-box border-base-content/20 bg-base-200 z-50 mt-1 max-h-72 w-48 flex-nowrap overflow-auto border p-2 shadow-sm"
|
||||
class="menu dropdown-content rounded-box border-base-content/20 bg-base-200 z-50 mt-1 w-52 border p-2 shadow-sm"
|
||||
>
|
||||
<slot>
|
||||
<li v-for="item in options">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label class="label text-base-content cursor-pointer gap-4">
|
||||
<div class="flex-1 whitespace-normal"><slot name="label" /></div>
|
||||
<div class="flex-1"><slot name="label" /></div>
|
||||
<slot name="input" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()">{{ text }}</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { date } = defineProps<{
|
||||
date: Date;
|
||||
}>();
|
||||
|
||||
const text = ref<string>();
|
||||
|
||||
const updateFromNow = () => {
|
||||
text.value = toRelativeTime(date, locale.value === "" ? undefined : locale.value);
|
||||
};
|
||||
watch(() => date, updateFromNow, { immediate: true });
|
||||
useIntervalFn(updateFromNow, 30_000);
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import { allLevels } from "@/composable/logContext";
|
||||
|
||||
export function useDownloadUrl(
|
||||
containers: Ref<Container[]> | ComputedRef<Container[]>,
|
||||
streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>,
|
||||
levels: Ref<Set<string>>,
|
||||
) {
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
const config = toValue(streamConfig);
|
||||
|
||||
// Add stdout/stderr
|
||||
if (config.stdout) params.append("stdout", "1");
|
||||
if (config.stderr) params.append("stderr", "1");
|
||||
|
||||
// Add filter if search is active
|
||||
if (debouncedSearchFilter.value) {
|
||||
params.append("filter", debouncedSearchFilter.value);
|
||||
}
|
||||
|
||||
// Add levels (multiple values) only if filtered
|
||||
const selectedLevels = Array.from(levels.value);
|
||||
if (selectedLevels.length > 0 && selectedLevels.length < allLevels.length) {
|
||||
selectedLevels.forEach((level) => params.append("levels", level));
|
||||
}
|
||||
|
||||
const containerIds = toValue(containers)
|
||||
.map((c) => c.host + "~" + c.id)
|
||||
.join(",");
|
||||
|
||||
return withBase(`/api/containers/${containerIds}/download?${params.toString()}`);
|
||||
});
|
||||
|
||||
const isFiltered = computed(
|
||||
() => debouncedSearchFilter.value || (levels.value.size > 0 && levels.value.size < allLevels.length),
|
||||
);
|
||||
|
||||
return {
|
||||
downloadUrl,
|
||||
isFiltered,
|
||||
};
|
||||
}
|
||||
@@ -4,27 +4,26 @@ import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
type JSONObject,
|
||||
type LogMessage,
|
||||
LogEntry,
|
||||
asLogEntry,
|
||||
ContainerEventLogEntry,
|
||||
ComplexLogEntry,
|
||||
SkippedLogsEntry,
|
||||
LoadMoreLogEntry,
|
||||
} from "@/models/LogEntry";
|
||||
import { Service, Stack } from "@/models/Stack";
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
function parseMessage(data: string): LogEntry<LogMessage> {
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`);
|
||||
return useLogStream(url, container);
|
||||
const loadMoreUrl = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
|
||||
return useLogStream(url, loadMoreUrl);
|
||||
}
|
||||
|
||||
export function useHostStream(host: Ref<Host>): LogStreamSource {
|
||||
@@ -32,8 +31,7 @@ export function useHostStream(host: Ref<Host>): LogStreamSource {
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
const labels = computed(() => `com.docker.stack.namespace:${stack.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
return useLogStream(computed(() => `/api/stacks/${stack.value.name}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource {
|
||||
@@ -50,31 +48,18 @@ export function useMergedStream(containers: Ref<Container[]>): LogStreamSource {
|
||||
}
|
||||
|
||||
export function useServiceStream(service: Ref<Service>): LogStreamSource {
|
||||
const labels = computed(() => `com.docker.swarm.service.name:${service.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
|
||||
const labels = computed(() => `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}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
return useLogStream(computed(() => `/api/services/${service.value.name}/logs/stream`));
|
||||
}
|
||||
|
||||
export type LogStreamSource = ReturnType<typeof useLogStream>;
|
||||
|
||||
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
const messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
const buffer: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
|
||||
const buffer: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const { paused: scrollingPaused } = useScrollContext();
|
||||
const { streamConfig, hasComplexLogs, levels, loadingMore } = useLoggingContext();
|
||||
let initial = true;
|
||||
|
||||
function flushNow() {
|
||||
if (messages.value.length + buffer.value.length > config.maxLogs) {
|
||||
@@ -86,9 +71,10 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
} else {
|
||||
const firstItem = buffer.value.at(0) as LogEntry<string | JSONObject>;
|
||||
const lastItem = buffer.value.at(-1) as LogEntry<string | JSONObject>;
|
||||
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
new SkippedLogsEntry(new Date(), buffer.value.length, firstItem, lastItem, loadSkippedLogs),
|
||||
new SkippedLogsEntry(new Date(), buffer.value.length, firstItem, lastItem),
|
||||
];
|
||||
}
|
||||
buffer.value = [];
|
||||
@@ -101,15 +87,9 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
} else {
|
||||
if (initial) {
|
||||
if (messages.value.length == 0) {
|
||||
// sort the buffer the very first time because of multiple logs in parallel
|
||||
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (container) {
|
||||
const loadMoreItem = new LoadMoreLogEntry(new Date(), loadOlderLogs);
|
||||
messages.value = [loadMoreItem];
|
||||
}
|
||||
initial = false;
|
||||
}
|
||||
messages.value = [...messages.value, ...buffer.value];
|
||||
buffer.value = [];
|
||||
@@ -131,6 +111,8 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
|
||||
|
||||
const params = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
@@ -150,7 +132,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
opened.value = false;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
initial = true;
|
||||
es = new EventSource(urlWithParams.value);
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as {
|
||||
@@ -194,48 +175,46 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
|
||||
watch(urlWithParams, () => connect(), { immediate: true });
|
||||
|
||||
async function loadOlderLogs(entry: LoadMoreLogEntry) {
|
||||
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
|
||||
if (!container) throw new Error("No container");
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
const [loader, ...existingLogs] = messages.value;
|
||||
const to = existingLogs[0].date;
|
||||
const lastSeenId = existingLogs[0].id;
|
||||
async function loadOlderLogs() {
|
||||
if (!loadMoreUrl) return;
|
||||
if (isLoadingMore.value) return;
|
||||
|
||||
const to = messages.value[0].date;
|
||||
const lastSeenId = messages.value[0].id;
|
||||
const last = messages.value[Math.min(messages.value.length - 1, 300)].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
isLoadingMore.value = true;
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const { logs: newLogs, signal } = await loadBetween(container, params, from, to, {
|
||||
min: 100,
|
||||
lastSeenId,
|
||||
const urlWithMoreParams = computed(() => {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
loadMoreParams.append("minimum", "100");
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
|
||||
return withBase(`${loadMoreUrl.value}?${loadMoreParams.toString()}`);
|
||||
});
|
||||
if (newLogs && signal.aborted === false) {
|
||||
messages.value = [loader, ...newLogs, ...existingLogs];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
|
||||
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
|
||||
stopWatcher();
|
||||
|
||||
async function loadSkippedLogs(entry: SkippedLogsEntry) {
|
||||
if (!container) throw new Error("No container");
|
||||
|
||||
const from = entry.firstSkipped.date;
|
||||
const to = entry.lastSkippedLog.date;
|
||||
const lastSeenId = entry.lastSkippedLog.id;
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const { logs, signal } = await loadBetween(container, params, from, to, { lastSeenId });
|
||||
if (logs && signal.aborted === false) {
|
||||
messages.value = messages.value.slice(logs.length).flatMap((log) => (log === entry ? logs : [log]));
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value = [...newMessages, ...messages.value];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (e) {
|
||||
console.error("Error loading older logs", e);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,49 +228,12 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
|
||||
return {
|
||||
messages,
|
||||
loadOlderLogs,
|
||||
isLoadingMore,
|
||||
hasComplexLogs,
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadBetween(
|
||||
container: Ref<Container>,
|
||||
params: Ref<URLSearchParams>,
|
||||
from: Date,
|
||||
to: Date,
|
||||
{ lastSeenId, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
|
||||
) {
|
||||
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const urlWithMoreParams = computed(() => {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
if (min) {
|
||||
loadMoreParams.append("min", String(min));
|
||||
}
|
||||
if (maxStart) {
|
||||
loadMoreParams.append("maxStart", String(maxStart));
|
||||
}
|
||||
if (lastSeenId) {
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
}
|
||||
return withBase(`${url.value}?${loadMoreParams.toString()}`);
|
||||
});
|
||||
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
|
||||
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
|
||||
stopWatcher();
|
||||
|
||||
if (!logs) return { logs: [], signal };
|
||||
|
||||
return {
|
||||
logs: logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line)),
|
||||
signal,
|
||||
eventSourceURL: urlWithParams,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { HistoricalContainer } from "@/models/Container";
|
||||
import { LogMessage, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
|
||||
import { ShallowRef } from "vue";
|
||||
import { loadBetween } from "@/composable/eventStreams";
|
||||
|
||||
export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalContainer>): LogStreamSource {
|
||||
const messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const container = toRef(() => historicalContainer.value.container);
|
||||
|
||||
const { streamConfig, levels, loadingMore } = useLoggingContext();
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
const params = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
return params;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
async function loadLogs() {
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const lastSeenId = route.query.logId ? +route.query.logId : undefined;
|
||||
const [{ logs: before }, { logs: after }] = await Promise.all([
|
||||
loadBetween(
|
||||
container,
|
||||
params,
|
||||
new Date(historicalContainer.value.date.getTime() - 1000 * 60 * 5),
|
||||
new Date(historicalContainer.value.date.getTime() + 1000),
|
||||
{
|
||||
min: 50,
|
||||
lastSeenId,
|
||||
},
|
||||
),
|
||||
loadBetween(container, params, historicalContainer.value.date, new Date(), {
|
||||
maxStart: 50,
|
||||
}),
|
||||
]);
|
||||
const loaderOlder = new LoadMoreLogEntry(new Date(), loadOlderLogs);
|
||||
const loadNewer = new LoadMoreLogEntry(new Date(), loadNewerLogs, false);
|
||||
messages.value = [loaderOlder, ...before, ...after, loadNewer];
|
||||
loading.value = false;
|
||||
opened.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watchArray([params, container], loadLogs, { immediate: true });
|
||||
|
||||
async function loadOlderLogs(entry: LoadMoreLogEntry) {
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const item = messages.value[1];
|
||||
const { logs, signal } = await loadBetween(
|
||||
container,
|
||||
params,
|
||||
new Date(item.date.getTime() - 1000 * 60 * 5),
|
||||
item.date,
|
||||
{
|
||||
min: 200,
|
||||
lastSeenId: item.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [loader, ...rest] = messages.value;
|
||||
messages.value = [loader, ...logs, ...rest];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNewerLogs(entry: LoadMoreLogEntry) {
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const item = messages.value.at(-2)!;
|
||||
const { logs, signal } = await loadBetween(container, params, item.date, new Date(), {
|
||||
maxStart: 100,
|
||||
});
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = messages.value.at(-1)!;
|
||||
const rest = messages.value.slice(0, -1);
|
||||
messages.value = [...rest, ...logs, loader];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ type LogContext = {
|
||||
levels: Set<Level>;
|
||||
showContainerName: boolean;
|
||||
showHostname: boolean;
|
||||
historical: boolean;
|
||||
};
|
||||
|
||||
export const allLevels: Level[] = ["info", "debug", "warn", "error", "fatal", "trace", "unknown"];
|
||||
@@ -22,7 +21,7 @@ const stderr = searchParams.has("stderr") ? searchParams.get("stderr") === "true
|
||||
|
||||
export const provideLoggingContext = (
|
||||
containers: Ref<Container[]>,
|
||||
{ showContainerName = false, showHostname = false, historical = false } = {},
|
||||
{ showContainerName = false, showHostname = false } = {},
|
||||
) => {
|
||||
provide(
|
||||
loggingContextKey,
|
||||
@@ -34,7 +33,6 @@ export const provideLoggingContext = (
|
||||
levels: new Set<Level>(allLevels),
|
||||
showContainerName,
|
||||
showHostname,
|
||||
historical,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -50,7 +48,6 @@ export const useLoggingContext = () => {
|
||||
levels: new Set<Level>(allLevels),
|
||||
showContainerName: false,
|
||||
showHostname: false,
|
||||
historical: false,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
type ScrollContext = {
|
||||
loading: boolean;
|
||||
paused: boolean;
|
||||
progress: number;
|
||||
currentDate: Date;
|
||||
@@ -8,18 +9,20 @@ type ScrollContext = {
|
||||
export const scrollContextKey = Symbol("scrollContext") as InjectionKey<ScrollContext>;
|
||||
|
||||
export const provideScrollContext = () => {
|
||||
const context = defaultValue();
|
||||
const context = defauleValue();
|
||||
provide(scrollContextKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useScrollContext = () => {
|
||||
const context = inject(scrollContextKey, defaultValue());
|
||||
const defaultValue = defauleValue();
|
||||
const context = inject(scrollContextKey, defaultValue);
|
||||
return toRefs(context);
|
||||
};
|
||||
|
||||
function defaultValue() {
|
||||
function defauleValue() {
|
||||
return reactive({
|
||||
loading: false,
|
||||
paused: false,
|
||||
progress: 1,
|
||||
currentDate: new Date(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ComplexLogEntry, type LogMessage, type LogEntry } from "@/models/LogEntry";
|
||||
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
|
||||
const { isSearching } = useSearchFilter();
|
||||
function filteredPayload(messages: Ref<LogEntry<LogMessage>[]>) {
|
||||
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
return computed(() => {
|
||||
return messages.value
|
||||
.map((d) => {
|
||||
|
||||
+1
-9
@@ -4,7 +4,7 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--color-green: oklch(0.62 0.119722 158.82);
|
||||
--color-green: oklch(69% 0.119722 188.479048);
|
||||
--color-red: oklch(64% 0.218 28.85);
|
||||
--color-purple: oklch(51.49% 0.215 321.03);
|
||||
--color-blue: oklch(65% 0.171 249.5);
|
||||
@@ -143,11 +143,3 @@ body {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
[class*="shadow-"] {
|
||||
@apply shadow-base-content/8;
|
||||
}
|
||||
|
||||
.splitpanes--vertical .splitpanes__pane {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,6 @@ export class GroupedContainers {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class HistoricalContainer {
|
||||
constructor(
|
||||
public readonly container: Container,
|
||||
public readonly date: Date,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Container {
|
||||
private _stat: Ref<Stat>;
|
||||
private _name: string;
|
||||
@@ -51,13 +44,10 @@ export class Container {
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
) {
|
||||
this._stat = ref(
|
||||
stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat),
|
||||
);
|
||||
this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat));
|
||||
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
|
||||
this._statsHistory = history;
|
||||
const { movingAverage } = useExponentialMovingAverage(this._stat, 0.2);
|
||||
this.movingAverageStat = movingAverage;
|
||||
this.movingAverageStat = useExponentialMovingAverage(this._stat, 0.2);
|
||||
|
||||
this._name = name;
|
||||
}
|
||||
@@ -83,11 +73,7 @@ export class Container {
|
||||
}
|
||||
|
||||
get namespace() {
|
||||
return (
|
||||
this.labels["dev.dozzle.group"] ||
|
||||
this.labels["com.docker.stack.namespace"] ||
|
||||
this.labels["com.docker.compose.project"]
|
||||
);
|
||||
return this.labels["com.docker.stack.namespace"] || this.labels["com.docker.compose.project"];
|
||||
}
|
||||
|
||||
get customGroup() {
|
||||
|
||||
+36
-91
@@ -2,17 +2,13 @@ import { Component, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON } from "@/utils";
|
||||
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
|
||||
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
|
||||
import GroupedLogItem from "@/components/LogViewer/GroupedLogItem.vue";
|
||||
import ContainerEventLogItem from "@/components/LogViewer/ContainerEventLogItem.vue";
|
||||
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
|
||||
import LoadMoreLogItem from "@/components/LogViewer/LoadMoreLogItem.vue";
|
||||
|
||||
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
|
||||
export type JSONObject = { [x: string]: JSONValue };
|
||||
export type Std = "stdout" | "stderr";
|
||||
export type LogType = "single" | "group" | "complex";
|
||||
export type Position = "start" | "end" | "middle" | undefined;
|
||||
export type LogMessage = string | string[] | JSONObject;
|
||||
export type Std = "stdout" | "stderr";
|
||||
export type Level =
|
||||
| "error"
|
||||
| "warn"
|
||||
@@ -24,23 +20,17 @@ export type Level =
|
||||
| "critical"
|
||||
| "fatal"
|
||||
| "unknown";
|
||||
|
||||
export interface LogFragment {
|
||||
readonly m: string;
|
||||
}
|
||||
|
||||
export interface LogEvent {
|
||||
readonly t: LogType;
|
||||
readonly m: string | LogFragment[] | JSONObject;
|
||||
readonly m: string | JSONObject;
|
||||
readonly ts: number;
|
||||
readonly id: number;
|
||||
readonly l: Level;
|
||||
readonly p: Position;
|
||||
readonly s: "stdout" | "stderr" | "unknown";
|
||||
readonly c: string;
|
||||
readonly rm: string;
|
||||
}
|
||||
|
||||
export abstract class LogEntry<T extends LogMessage> {
|
||||
export abstract class LogEntry<T extends string | JSONObject> {
|
||||
protected readonly _message: T;
|
||||
constructor(
|
||||
message: T,
|
||||
@@ -48,7 +38,6 @@ export abstract class LogEntry<T extends LogMessage> {
|
||||
public readonly id: number,
|
||||
public readonly date: Date,
|
||||
public readonly std: Std,
|
||||
public readonly rawMessage: string,
|
||||
public readonly level?: Level,
|
||||
) {
|
||||
this._message = message;
|
||||
@@ -68,37 +57,16 @@ export class SimpleLogEntry extends LogEntry<string> {
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly position: Position,
|
||||
public readonly std: Std,
|
||||
public readonly rawMessage: string,
|
||||
) {
|
||||
super(message, containerID, id, date, std, rawMessage, level);
|
||||
super(message, containerID, id, date, std, level);
|
||||
}
|
||||
getComponent(): Component {
|
||||
return SimpleLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupedLogEntry extends LogEntry<string[]> {
|
||||
constructor(
|
||||
messages: string[],
|
||||
containerID: string,
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly std: Std,
|
||||
) {
|
||||
super(messages as any, containerID, id, date, std, "", level);
|
||||
}
|
||||
|
||||
public get message(): string[] {
|
||||
return this._message as unknown as string[];
|
||||
}
|
||||
|
||||
getComponent(): Component {
|
||||
return GroupedLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
private readonly filteredMessage: ComputedRef<Record<string, any>>;
|
||||
|
||||
@@ -109,10 +77,9 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly std: Std,
|
||||
public readonly rawMessage: string,
|
||||
visibleKeys?: Ref<Map<string[], boolean>>,
|
||||
) {
|
||||
super(message, containerID, id, date, std, rawMessage, level);
|
||||
super(message, containerID, id, date, std, level);
|
||||
if (visibleKeys) {
|
||||
this.filteredMessage = computed(() => {
|
||||
if (visibleKeys.value.size === 0) {
|
||||
@@ -156,7 +123,6 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
event.date,
|
||||
event.level,
|
||||
event.std,
|
||||
event.rawMessage,
|
||||
visibleKeys,
|
||||
);
|
||||
}
|
||||
@@ -177,7 +143,7 @@ export class ContainerEventLogEntry extends LogEntry<string> {
|
||||
}
|
||||
|
||||
export class SkippedLogsEntry extends LogEntry<string> {
|
||||
private _totalSkipped = ref(0);
|
||||
private _totalSkipped = 0;
|
||||
private lastSkipped: LogEntry<string | JSONObject>;
|
||||
|
||||
constructor(
|
||||
@@ -185,10 +151,9 @@ export class SkippedLogsEntry extends LogEntry<string> {
|
||||
totalSkipped: number,
|
||||
public readonly firstSkipped: LogEntry<string | JSONObject>,
|
||||
lastSkipped: LogEntry<string | JSONObject>,
|
||||
private readonly loader: (i: SkippedLogsEntry) => Promise<void>,
|
||||
) {
|
||||
super("", "", date.getTime(), date, "stderr", "info");
|
||||
this._totalSkipped.value = totalSkipped;
|
||||
this._totalSkipped = totalSkipped;
|
||||
this.lastSkipped = lastSkipped;
|
||||
}
|
||||
getComponent(): Component {
|
||||
@@ -196,62 +161,42 @@ export class SkippedLogsEntry extends LogEntry<string> {
|
||||
}
|
||||
|
||||
public get message(): string {
|
||||
return `Skipped ${this._totalSkipped.value} entries`;
|
||||
return `Skipped ${this.totalSkipped} entries`;
|
||||
}
|
||||
|
||||
public addSkippedEntries(totalSkipped: number, lastItem: LogEntry<string | JSONObject>) {
|
||||
this._totalSkipped.value += totalSkipped;
|
||||
this._totalSkipped += totalSkipped;
|
||||
this.lastSkipped = lastItem;
|
||||
}
|
||||
|
||||
public get lastSkippedLog(): LogEntry<string | JSONObject> {
|
||||
public get totalSkipped(): number {
|
||||
return this._totalSkipped;
|
||||
}
|
||||
|
||||
public get lastSkippedItem(): LogEntry<string | JSONObject> {
|
||||
return this.lastSkipped;
|
||||
}
|
||||
|
||||
public async loadSkippedEntries(): Promise<void> {
|
||||
await this.loader(this);
|
||||
}
|
||||
|
||||
public get totalSkipped(): number {
|
||||
return unref(this._totalSkipped);
|
||||
}
|
||||
}
|
||||
|
||||
export class LoadMoreLogEntry extends LogEntry<string> {
|
||||
constructor(
|
||||
date: Date,
|
||||
private readonly loader: (i: LoadMoreLogEntry) => Promise<void>,
|
||||
public readonly rememberScrollPosition: boolean = true,
|
||||
) {
|
||||
super("", "", date.getTime(), date, "stderr", "info");
|
||||
}
|
||||
|
||||
getComponent(): Component {
|
||||
return LoadMoreLogItem;
|
||||
}
|
||||
|
||||
async loadMore(): Promise<void> {
|
||||
await this.loader(this);
|
||||
}
|
||||
}
|
||||
|
||||
export function asLogEntry(event: LogEvent): LogEntry<LogMessage> {
|
||||
const std = event.s === "unknown" ? "stderr" : (event.s ?? "stderr");
|
||||
|
||||
switch (event.t) {
|
||||
case "complex":
|
||||
return new ComplexLogEntry(event.m as JSONObject, event.c, event.id, new Date(event.ts), event.l, std, event.rm);
|
||||
case "group":
|
||||
return new GroupedLogEntry(
|
||||
(event.m as LogFragment[]).map((f) => f.m),
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
std,
|
||||
);
|
||||
case "single":
|
||||
default:
|
||||
return new SimpleLogEntry(event.m as string, event.c, event.id, new Date(event.ts), event.l, std, event.rm);
|
||||
export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
|
||||
if (isObject(event.m)) {
|
||||
return new ComplexLogEntry(
|
||||
event.m,
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
|
||||
);
|
||||
} else {
|
||||
return new SimpleLogEntry(
|
||||
event.m,
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
event.p,
|
||||
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setupLayouts } from "virtual:generated-layouts";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(withBase("/")),
|
||||
routes: setupLayouts([...routes]),
|
||||
routes: setupLayouts(routes),
|
||||
});
|
||||
|
||||
export const install = (app: App) => {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<HistoricalContainerLog :id :date show-title :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
|
||||
<div v-else-if="ready" class="hero bg-base-200 min-h-screen">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<p class="py-6 text-2xl font-bold">{{ $t("error.container-not-found") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/container/[id].time.[datetime]");
|
||||
const id = toRef(() => route.params.id);
|
||||
const date = toRef(() => new Date(route.params.datetime));
|
||||
const containerStore = useContainerStore();
|
||||
const currentContainer = containerStore.currentContainer(id);
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (currentContainer.value) {
|
||||
setTitle(currentContainer.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: host
|
||||
</route>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Search />
|
||||
<ContainerLog :id show-title :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
|
||||
<ContainerLog :id="id" :show-title="true" :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
|
||||
<div v-else-if="ready" class="hero bg-base-200 min-h-screen">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
@@ -39,23 +39,17 @@ watch(currentContainer, () => (redirectTrigger.value = false));
|
||||
|
||||
watchEffect(() => {
|
||||
if (redirectTrigger.value) return;
|
||||
if (automaticRedirect.value === "none") return;
|
||||
if (!currentContainer.value) return;
|
||||
if (currentContainer.value.state === "running") return;
|
||||
if (Date.now() - +currentContainer.value.finishedAt > 5 * 60 * 1000) return;
|
||||
|
||||
const nextContainer = allContainers.value
|
||||
.filter(
|
||||
(c) =>
|
||||
c.startedAt > currentContainer.value.startedAt &&
|
||||
c.name === currentContainer.value.name &&
|
||||
c.host === currentContainer.value.host,
|
||||
)
|
||||
.filter((c) => c.startedAt > currentContainer.value.startedAt && c.name === currentContainer.value.name)
|
||||
.sort((a, b) => +a.created - +b.created)[0];
|
||||
|
||||
if (!nextContainer) return;
|
||||
|
||||
if (automaticRedirect.value === "delayed") {
|
||||
if (automaticRedirect.value) {
|
||||
redirectTrigger.value = true;
|
||||
showToast(
|
||||
{
|
||||
@@ -79,16 +73,6 @@ watchEffect(() => {
|
||||
},
|
||||
{ timed: 4000 },
|
||||
);
|
||||
} else {
|
||||
router.push({ name: "/container/[id]", params: { id: nextContainer.id } });
|
||||
showToast(
|
||||
{
|
||||
title: t("alert.redirected.title"),
|
||||
message: t("alert.redirected.message", { containerId: nextContainer.id }),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 3000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+2
-37
@@ -1,29 +1,11 @@
|
||||
<template>
|
||||
<PageWithLinks>
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">{{ $t("label.host-count", { count: Object.keys(hosts).length }) }}</h2>
|
||||
<button @click="hostsCollapsed = !hostsCollapsed" class="btn btn-ghost btn-sm">
|
||||
<mdi:chevron-down :class="{ 'rotate-180': !hostsCollapsed }" class="transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="collapse">
|
||||
<HostList v-show="!hostsCollapsed" />
|
||||
</Transition>
|
||||
<HostList />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ $t("label.container", { count: runningContainers.length }) }}
|
||||
</h2>
|
||||
<button @click="containersCollapsed = !containersCollapsed" class="btn btn-ghost btn-sm">
|
||||
<mdi:chevron-down :class="{ 'rotate-180': !containersCollapsed }" class="transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="collapse">
|
||||
<ContainerTable v-show="!containersCollapsed" :containers="runningContainers" />
|
||||
</Transition>
|
||||
<ContainerTable :containers="runningContainers"></ContainerTable>
|
||||
</section>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
@@ -32,7 +14,6 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
@@ -42,10 +23,6 @@ const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
// Persist collapse state in localStorage
|
||||
const hostsCollapsed = useStorage("DOZZLE_HOSTS_COLLAPSED", false);
|
||||
const containersCollapsed = useStorage("DOZZLE_CONTAINERS_COLLAPSED", false);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
setTitle(t("title.dashboard", { count: runningContainers.value.length }));
|
||||
@@ -57,16 +34,4 @@ watchEffect(() => {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.collapse-enter-active,
|
||||
.collapse-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapse-enter-from,
|
||||
.collapse-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<Search />
|
||||
<NamespaceLog :namespace="namespace" :scrollable="pinnedLogs.length > 0" v-if="namespace" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/namespace/[name]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { namespaces } = storeToRefs(k8sStore);
|
||||
const namespace = computed(() => namespaces.value.find((ns) => ns.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (namespace.value?.name) {
|
||||
setTitle(namespace.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: k8s
|
||||
</route>
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<Search />
|
||||
<OwnerLog :owner="owner" :scrollable="pinnedLogs.length > 0" v-if="owner" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/owner/[name]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { owners } = storeToRefs(k8sStore);
|
||||
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (owner.value?.name) {
|
||||
setTitle(`${owner.value.kind}/${owner.value.name}`);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: k8s
|
||||
</route>
|
||||
+35
-83
@@ -5,35 +5,12 @@
|
||||
<h2>{{ $t("settings.about") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<div>
|
||||
<span v-html="$t('settings.using-version', { version: config.version })"></span>
|
||||
<span
|
||||
<div
|
||||
v-if="hasRelease"
|
||||
v-html="$t('settings.update-available', { nextVersion: latestRelease?.name, href: latestRelease?.htmlUrl })"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ $t("settings.help-support") }}
|
||||
|
||||
<ul class="mt-6 flex gap-2">
|
||||
<li>
|
||||
<a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer" class="btn">
|
||||
<mdi:github /> amir20/dozzle
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://buymeacoffee.com/amirraminfar"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<mdi:beer />
|
||||
Buy me a beer
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -48,7 +25,7 @@
|
||||
|
||||
<Toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </Toggle>
|
||||
|
||||
<Toggle v-model="showTimestamp">{{ $t("settings.show-timestamps") }}</Toggle>
|
||||
<Toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</Toggle>
|
||||
|
||||
<Toggle v-model="showStd">{{ $t("settings.show-std") }}</Toggle>
|
||||
|
||||
@@ -88,9 +65,9 @@
|
||||
<DropdownMenu
|
||||
v-model="hourStyle"
|
||||
:options="[
|
||||
{ label: $t('settings.hour.auto'), value: 'auto' },
|
||||
{ label: $t('settings.hour.12'), value: '12' },
|
||||
{ label: $t('settings.hour.24'), value: '24' },
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: '12', value: '12' },
|
||||
{ label: '24', value: '24' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
@@ -105,9 +82,9 @@
|
||||
<DropdownMenu
|
||||
v-model="size"
|
||||
:options="[
|
||||
{ label: $t('settings.size.small'), value: 'small' },
|
||||
{ label: $t('settings.size.medium'), value: 'medium' },
|
||||
{ label: $t('settings.size.large'), value: 'large' },
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
@@ -121,9 +98,9 @@
|
||||
<DropdownMenu
|
||||
v-model="lightTheme"
|
||||
:options="[
|
||||
{ label: $t('settings.theme.auto'), value: 'auto' },
|
||||
{ label: $t('settings.theme.dark'), value: 'dark' },
|
||||
{ label: $t('settings.theme.light'), value: 'light' },
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
@@ -142,49 +119,19 @@
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("settings.options") }}</h2>
|
||||
</div>
|
||||
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.automatic-redirect") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<DropdownMenu
|
||||
v-model="automaticRedirect"
|
||||
:options="[
|
||||
{ label: $t('settings.redirect.instant'), value: 'instant' },
|
||||
{ label: $t('settings.redirect.delayed'), value: 'delayed' },
|
||||
{ label: $t('settings.redirect.none'), value: 'none' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.group-containers") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<DropdownMenu
|
||||
v-model="groupContainers"
|
||||
:options="[
|
||||
{ label: $t('settings.grouping.always'), value: 'always' },
|
||||
{ label: $t('settings.grouping.at-least-2'), value: 'at-least-2' },
|
||||
{ label: $t('settings.grouping.never'), value: 'never' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
|
||||
<Toggle v-model="search">
|
||||
{{ $t("settings.search") }} <key-shortcut char="f" class="align-top"></key-shortcut>
|
||||
</Toggle>
|
||||
|
||||
<Toggle v-model="showAllContainers">{{ $t("settings.show-stopped-containers") }}</Toggle>
|
||||
|
||||
<Toggle v-model="automaticRedirect">{{ $t("settings.automatic-redirect") }}</Toggle>
|
||||
</section>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ComplexLogEntry, SimpleLogEntry, GroupedLogEntry } from "@/models/LogEntry";
|
||||
import { ComplexLogEntry, SimpleLogEntry } from "@/models/LogEntry";
|
||||
|
||||
import {
|
||||
automaticRedirect,
|
||||
@@ -200,7 +147,6 @@ import {
|
||||
smallerScrollbars,
|
||||
softWrap,
|
||||
locale,
|
||||
groupContainers,
|
||||
} from "@/stores/settings";
|
||||
|
||||
import { availableLocales, i18n } from "@/modules/i18n";
|
||||
@@ -220,20 +166,27 @@ const hoursAgo = (hours: number) => {
|
||||
const fakeMessages = computedWithControl(
|
||||
() => i18n.global.locale.value,
|
||||
() => [
|
||||
new SimpleLogEntry(t("settings.log.preview"), "123", 1, hoursAgo(16), "info", "stdout", ""),
|
||||
new SimpleLogEntry(t("settings.log.warning"), "123", 2, hoursAgo(12), "warn", "stdout", ""),
|
||||
new GroupedLogEntry(
|
||||
[
|
||||
t("settings.log.multi-line-error.start-line"),
|
||||
t("settings.log.multi-line-error.middle-line"),
|
||||
t("settings.log.multi-line-error.end-line"),
|
||||
],
|
||||
new SimpleLogEntry(t("settings.log.preview"), "123", 1, hoursAgo(16), "info", undefined, "stdout"),
|
||||
new SimpleLogEntry(t("settings.log.warning"), "123", 2, hoursAgo(12), "warn", undefined, "stdout"),
|
||||
new SimpleLogEntry(
|
||||
t("settings.log.multi-line-error.start-line"),
|
||||
"123",
|
||||
3,
|
||||
hoursAgo(7),
|
||||
"error",
|
||||
"start",
|
||||
"stderr",
|
||||
),
|
||||
new SimpleLogEntry(
|
||||
t("settings.log.multi-line-error.middle-line"),
|
||||
"123",
|
||||
4,
|
||||
hoursAgo(2),
|
||||
"error",
|
||||
"middle",
|
||||
"stderr",
|
||||
),
|
||||
new SimpleLogEntry(t("settings.log.multi-line-error.end-line"), "123", 5, new Date(), "error", "end", "stderr"),
|
||||
new ComplexLogEntry(
|
||||
{
|
||||
message: t("settings.log.complex"),
|
||||
@@ -247,9 +200,8 @@ const fakeMessages = computedWithControl(
|
||||
new Date(),
|
||||
"info",
|
||||
"stdout",
|
||||
"",
|
||||
),
|
||||
new SimpleLogEntry(t("settings.log.simple"), "123", 7, new Date(), "debug", "stderr", ""),
|
||||
new SimpleLogEntry(t("settings.log.simple"), "123", 7, new Date(), "debug", undefined, "stderr"),
|
||||
],
|
||||
);
|
||||
</script>
|
||||
@@ -257,14 +209,14 @@ const fakeMessages = computedWithControl(
|
||||
@reference "@/main.css";
|
||||
|
||||
.has-underline {
|
||||
@apply border-base-content/50 mb-4 border-b py-2;
|
||||
@apply border-base-content/50 mb-4 border-b py-4;
|
||||
|
||||
h2 {
|
||||
@apply text-3xl;
|
||||
@apply text-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(a:not(.menu a):not(.btn)) {
|
||||
:deep(a:not(.menu a)) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,25 +12,7 @@ type Announcement = {
|
||||
breaking: number;
|
||||
};
|
||||
|
||||
const releases = ref<Announcement[]>([]);
|
||||
let fetched = false;
|
||||
|
||||
async function fetchReleases() {
|
||||
if (fetched) return;
|
||||
fetched = true;
|
||||
|
||||
try {
|
||||
const { data } = await useFetch(withBase("/api/releases")).get().json<Announcement[]>();
|
||||
releases.value = data.value || [];
|
||||
} catch (error) {
|
||||
console.error("Error while fetching releases:\n", error);
|
||||
fetched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.releaseCheckMode === "automatic") {
|
||||
fetchReleases();
|
||||
}
|
||||
const { data: releases } = useFetch(withBase("/api/releases")).get().json<Announcement[]>();
|
||||
|
||||
const otherAnnouncements = [] as Announcement[];
|
||||
|
||||
@@ -51,6 +33,5 @@ export function useAnnouncements() {
|
||||
announcements,
|
||||
latestRelease,
|
||||
hasRelease,
|
||||
fetchReleases,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,15 +8,10 @@ export interface Config {
|
||||
base: string;
|
||||
maxLogs: number;
|
||||
hostname: string;
|
||||
mode: "server" | "swarm" | "k8s";
|
||||
hosts: Host[];
|
||||
authProvider: "simple" | "none" | "forward-proxy";
|
||||
logoutUrl?: string;
|
||||
enableActions: boolean;
|
||||
enableShell: boolean;
|
||||
enableDownload: boolean;
|
||||
disableAvatars: boolean;
|
||||
releaseCheckMode: "automatic" | "manual";
|
||||
user?: {
|
||||
username: string;
|
||||
email: string;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
export class K8sNamespace {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly containers: Container[],
|
||||
public readonly owners: K8sOwner[],
|
||||
) {
|
||||
for (const owner of owners) {
|
||||
owner.namespace = this;
|
||||
}
|
||||
}
|
||||
|
||||
get updatedAt() {
|
||||
return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0));
|
||||
}
|
||||
}
|
||||
|
||||
export class K8sOwner {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly kind: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
|
||||
namespace?: K8sNamespace;
|
||||
|
||||
get updatedAt() {
|
||||
return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0));
|
||||
}
|
||||
}
|
||||
|
||||
export const useK8sStore = defineStore("k8s", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
const namespaces = computed(() => {
|
||||
const namespacedContainers: Record<string, Container[]> = {};
|
||||
for (const container of runningContainers.value) {
|
||||
const namespace = container.labels["namespace"];
|
||||
if (namespace === undefined) continue;
|
||||
namespacedContainers[namespace] ||= [];
|
||||
namespacedContainers[namespace].push(container);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
if (newOwners.length === 0) continue;
|
||||
|
||||
newNamespaces.push(
|
||||
new K8sNamespace(
|
||||
name,
|
||||
containers,
|
||||
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return newNamespaces.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
const ownerGroups: Record<string, 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);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
const customGroups = computed(() => {
|
||||
const grouped: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const group = container.customGroup;
|
||||
if (group === undefined) continue;
|
||||
grouped[group] ||= [];
|
||||
grouped[group].push(container);
|
||||
}
|
||||
|
||||
return Object.entries(grouped).map(([name, containers]) => new GroupedContainers(name, containers));
|
||||
});
|
||||
|
||||
return {
|
||||
namespaces,
|
||||
owners,
|
||||
customGroups,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useK8sStore, import.meta.hot));
|
||||
}
|
||||
@@ -14,9 +14,8 @@ export type Settings = {
|
||||
dateLocale: "auto" | "en-US" | "en-GB" | "de-DE" | "en-CA";
|
||||
softWrap: boolean;
|
||||
collapseNav: boolean;
|
||||
automaticRedirect: "instant" | "delayed" | "none";
|
||||
automaticRedirect: boolean;
|
||||
locale: string;
|
||||
groupContainers: "always" | "at-least-2" | "never";
|
||||
};
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
search: true,
|
||||
@@ -32,21 +31,12 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
dateLocale: "auto",
|
||||
softWrap: true,
|
||||
collapseNav: false,
|
||||
automaticRedirect: "delayed",
|
||||
automaticRedirect: true,
|
||||
locale: "",
|
||||
groupContainers: "at-least-2",
|
||||
};
|
||||
|
||||
export const settings = useProfileStorage("settings", DEFAULT_SETTINGS);
|
||||
|
||||
// @ts-ignore: automaticRedirect is now a string enum, but might be a boolean in older data
|
||||
if (settings.value.automaticRedirect === true) {
|
||||
settings.value.automaticRedirect = "delayed";
|
||||
// @ts-ignore: automaticRedirect is now a string enum, but might be a boolean in older data
|
||||
} else if (settings.value.automaticRedirect === false) {
|
||||
settings.value.automaticRedirect = "none";
|
||||
}
|
||||
|
||||
export const {
|
||||
collapseNav,
|
||||
compact,
|
||||
@@ -63,5 +53,4 @@ export const {
|
||||
search,
|
||||
locale,
|
||||
automaticRedirect,
|
||||
groupContainers,
|
||||
} = toRefs(settings.value);
|
||||
} = toRefs(settings);
|
||||
|
||||
Vendored
+12
-212
@@ -1,15 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection ES6UnusedImports
|
||||
// Generated by unplugin-vue-router. !! DO NOT MODIFY THIS FILE !!
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-resolver' {
|
||||
export type ParamParserCustom = never
|
||||
}
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
@@ -23,211 +18,16 @@ declare module 'vue-router/auto-routes' {
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<
|
||||
'/',
|
||||
'/',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/[...all]': RouteRecordInfo<
|
||||
'/[...all]',
|
||||
'/:all(.*)',
|
||||
{ all: ParamValue<true> },
|
||||
{ all: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/container/[id]': RouteRecordInfo<
|
||||
'/container/[id]',
|
||||
'/container/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/container/[id].time.[datetime]': RouteRecordInfo<
|
||||
'/container/[id].time.[datetime]',
|
||||
'/container/:id/time/:datetime',
|
||||
{ id: ParamValue<true>, datetime: ParamValue<true> },
|
||||
{ id: ParamValue<false>, datetime: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/group/[name]': RouteRecordInfo<
|
||||
'/group/[name]',
|
||||
'/group/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/host/[id]': RouteRecordInfo<
|
||||
'/host/[id]',
|
||||
'/host/:id',
|
||||
{ id: ParamValue<true> },
|
||||
{ id: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/login': RouteRecordInfo<
|
||||
'/login',
|
||||
'/login',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/merged/[ids]': RouteRecordInfo<
|
||||
'/merged/[ids]',
|
||||
'/merged/:ids',
|
||||
{ ids: ParamValue<true> },
|
||||
{ ids: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/namespace/[name]': RouteRecordInfo<
|
||||
'/namespace/[name]',
|
||||
'/namespace/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/owner/[name]': RouteRecordInfo<
|
||||
'/owner/[name]',
|
||||
'/owner/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/service/[name]': RouteRecordInfo<
|
||||
'/service/[name]',
|
||||
'/service/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/settings': RouteRecordInfo<
|
||||
'/settings',
|
||||
'/settings',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/show': RouteRecordInfo<
|
||||
'/show',
|
||||
'/show',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/stack/[name]': RouteRecordInfo<
|
||||
'/stack/[name]',
|
||||
'/stack/:name',
|
||||
{ name: ParamValue<true> },
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
|
||||
'/container/[id]': RouteRecordInfo<'/container/[id]', '/container/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'/group/[name]': RouteRecordInfo<'/group/[name]', '/group/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
'/host/[id]': RouteRecordInfo<'/host/[id]', '/host/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
|
||||
'/merged/[ids]': RouteRecordInfo<'/merged/[ids]', '/merged/:ids', { ids: ParamValue<true> }, { ids: ParamValue<false> }>,
|
||||
'/service/[name]': RouteRecordInfo<'/service/[name]', '/service/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
'/settings': RouteRecordInfo<'/settings', '/settings', Record<never, never>, Record<never, never>>,
|
||||
'/show': RouteRecordInfo<'/show', '/show', Record<never, never>, Record<never, never>>,
|
||||
'/stack/[name]': RouteRecordInfo<'/stack/[name]', '/stack/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Route file to route info map by unplugin-vue-router.
|
||||
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
|
||||
*
|
||||
* Each key is a file path relative to the project root with 2 properties:
|
||||
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
|
||||
* - views: names of nested views (can be passed to <RouterView name="...">)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface _RouteFileInfoMap {
|
||||
'assets/pages/index.vue': {
|
||||
routes:
|
||||
| '/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/[...all].vue': {
|
||||
routes:
|
||||
| '/[...all]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/container/[id].vue': {
|
||||
routes:
|
||||
| '/container/[id]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/container/[id].time.[datetime].vue': {
|
||||
routes:
|
||||
| '/container/[id].time.[datetime]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/group/[name].vue': {
|
||||
routes:
|
||||
| '/group/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/host/[id].vue': {
|
||||
routes:
|
||||
| '/host/[id]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/login.vue': {
|
||||
routes:
|
||||
| '/login'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/merged/[ids].vue': {
|
||||
routes:
|
||||
| '/merged/[ids]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/namespace/[name].vue': {
|
||||
routes:
|
||||
| '/namespace/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/owner/[name].vue': {
|
||||
routes:
|
||||
| '/owner/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/service/[name].vue': {
|
||||
routes:
|
||||
| '/service/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/settings.vue': {
|
||||
routes:
|
||||
| '/settings'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/show.vue': {
|
||||
routes:
|
||||
| '/show'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/stack/[name].vue': {
|
||||
routes:
|
||||
| '/stack/[name]'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a union of possible route names in a certain route component file.
|
||||
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type _RouteNamesForFilePath<FilePath extends string> =
|
||||
_RouteFileInfoMap extends Record<FilePath, infer Info>
|
||||
? Info['routes']
|
||||
: keyof RouteNamedMap
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -3,8 +3,6 @@ export interface ContainerStat {
|
||||
readonly cpu: number;
|
||||
readonly memory: number;
|
||||
readonly memoryUsage: number;
|
||||
readonly networkRxTotal: number;
|
||||
readonly networkTxTotal: number;
|
||||
}
|
||||
|
||||
export type ContainerJson = {
|
||||
|
||||
+2
-26
@@ -2,7 +2,7 @@ export function formatBytes(
|
||||
bytes: number,
|
||||
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
|
||||
) {
|
||||
if (bytes === 0) return short ? "0B" : "0 Bytes";
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
@@ -70,7 +70,7 @@ export function useExponentialMovingAverage<T extends Record<string, number>>(so
|
||||
ema.value = newValue as T;
|
||||
});
|
||||
|
||||
return { movingAverage: ema, reset: (value: T) => (ema.value = value) };
|
||||
return ema;
|
||||
}
|
||||
|
||||
interface UseSimpleRefHistoryOptions<T> {
|
||||
@@ -109,27 +109,3 @@ export function hashCode(str: string) {
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["week", 604800],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1],
|
||||
];
|
||||
|
||||
export function toRelativeTime(date: Date, locale: string | undefined): string {
|
||||
const diffInSeconds = (date.getTime() - new Date().getTime()) / 1000;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
for (const [unit, seconds] of units) {
|
||||
const value = Math.round(diffInSeconds / seconds);
|
||||
if (Math.abs(value) >= 1) {
|
||||
return rtf.format(value, unit);
|
||||
}
|
||||
}
|
||||
|
||||
return rtf.format(0, "second");
|
||||
}
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ services:
|
||||
|
||||
playwright:
|
||||
container_name: playwright
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
@@ -57,7 +57,6 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "What is Dozzle?", link: "/guide/what-is-dozzle" },
|
||||
{ text: "Getting Started", link: "/guide/getting-started" },
|
||||
{ text: "Introducing dtop 🚀", link: "/guide/dtop" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+1
-4
@@ -1,11 +1,8 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
title: Container Actions
|
||||
---
|
||||
|
||||
# Container Actions
|
||||
|
||||
<Badge type="warning" text="Docker Only" />
|
||||
# Using Container Actions
|
||||
|
||||
Dozzle supports container actions, which allows you to `start`, `stop` and `restart` containers from the dropdown menu on the right next to the container stats. This feature is **disabled** by default and can be enabled by setting the environment variable `DOZZLE_ENABLE_ACTIONS` to `true`.
|
||||
|
||||
|
||||
+3
-49
@@ -2,9 +2,7 @@
|
||||
title: Agent Mode
|
||||
---
|
||||
|
||||
# Agent Mode
|
||||
|
||||
<Badge type="warning" text="Docker Only" />
|
||||
# Agent Mode <Badge type="warning" text="Docker Only" />
|
||||
|
||||
Dozzle can run in agent mode which can expose Docker hosts to other Dozzle instances. All communication is done over a secured connection using TLS. This means that you can deploy Dozzle on a remote host and connect to it from your local machine.
|
||||
|
||||
@@ -34,9 +32,6 @@ services:
|
||||
|
||||
:::
|
||||
|
||||
> [!NOTE] Docker Socket Proxy users
|
||||
> If you are using a remote agent you **CANNOT** add a socket proxy on top of the agent. Dozzle agents **REPLACE** using a proxy, see [Remote Hosts](/guide/remote-hosts.md) for more info and how to use a socket proxy instead of an agent.
|
||||
|
||||
The agent will start and listen on port `7007`. You can connect to the agent using the Dozzle UI by providing the agent's IP address and port. The agent will only show the containers that are available on the host where the agent is running.
|
||||
|
||||
> [!TIP]
|
||||
@@ -154,9 +149,7 @@ This will restrict the agent to displaying only containers with the label `color
|
||||
|
||||
By default, Dozzle uses self-signed certificates for communication between agents. This is a private certificate which is only valid to other Dozzle instances. This is secure and recommended for most use cases. However, if Dozzle is exposed externally and an attacker knows exactly which port the agent is running on, then they can set up their own Dozzle instance and connect to the agent. To prevent this, you can provide your own certificates.
|
||||
|
||||
To provide custom certificates, you need to mount or use secrets to provide the certificates. By default, Dozzle looks for certificates at `/dozzle_cert.pem` and `/dozzle_key.pem`, but you can customize these paths using the `--cert` and `--key` flags or the `DOZZLE_CERT` and `DOZZLE_KEY` environment variables.
|
||||
|
||||
Here is an example using the default paths:
|
||||
To provide custom certificates, you need to mount or use secrets to provide the certificates. Here is an example:
|
||||
|
||||
```yml
|
||||
services:
|
||||
@@ -179,49 +172,10 @@ secrets:
|
||||
file: ./key.pem
|
||||
```
|
||||
|
||||
Or using custom paths with environment variables:
|
||||
|
||||
```yml
|
||||
services:
|
||||
agent:
|
||||
image: amir20/dozzle:latest
|
||||
command: agent
|
||||
environment:
|
||||
- DOZZLE_CERT=/certs/my-cert.pem
|
||||
- DOZZLE_KEY=/certs/my-key.pem
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./certs:/certs
|
||||
ports:
|
||||
- 7007:7007
|
||||
```
|
||||
|
||||
Or using command-line flags:
|
||||
|
||||
::: code-group
|
||||
|
||||
```sh
|
||||
docker run -v /var/run/docker.sock:/var/run/docker.sock -v ./certs:/certs -p 7007:7007 amir20/dozzle:latest agent --cert /certs/my-cert.pem --key /certs/my-key.pem
|
||||
```
|
||||
|
||||
```yaml [docker-compose.yml]
|
||||
services:
|
||||
agent:
|
||||
image: amir20/dozzle:latest
|
||||
command: agent --cert /certs/my-cert.pem --key /certs/my-key.pem
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./certs:/certs
|
||||
ports:
|
||||
- 7007:7007
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
> [!TIP]
|
||||
> Docker secrets are preferred for providing certificates. They can be created using `docker secret create` command or as the example above using `docker-compose.yml`. The same certificates should be provided to the Dozzle instance connecting to the agent.
|
||||
|
||||
This will mount the certificate and key files to the agent. The agent will use these certificates for communication. The same certificates should be provided to the Dozzle instance connecting to the agent.
|
||||
This will mount the `cert.pem` and `key.pem` files to the agent. The agent will use these certificates for communication. The same certificates should be provided to the Dozzle instance connecting to the agent.
|
||||
|
||||
To generate certificates, you can use the following command:
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user