mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 13:34:45 +00:00
Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2eb5ec47 | |||
| 8358b98111 | |||
| af3aefe830 | |||
| ebc54a4481 | |||
| 8cf7ccd5ee | |||
| a9395e9f73 | |||
| b9df31356f | |||
| d2abd0e279 | |||
| 8a6ce3b21d | |||
| 239850c52d | |||
| 6b84105e79 | |||
| c159db5f0b | |||
| a18edf03ec | |||
| 18acf6722b | |||
| 5e0a4c43e6 | |||
| 48f04a0a7d | |||
| ae1753bcee | |||
| 3ae53dd719 | |||
| 3e0ed70362 | |||
| c6c64fdd86 | |||
| 616dd91483 | |||
| f952961eea | |||
| 3ca08f781b | |||
| 03011eea97 | |||
| 3783125b6a | |||
| fe6549e5f5 | |||
| cedb384dc4 | |||
| 163452a398 | |||
| f348b57721 | |||
| df5a60479c | |||
| 9c665d42a9 | |||
| 49f625a1db | |||
| af38e3a0c0 | |||
| 104749063d | |||
| d7b896e3d8 | |||
| 5693bcc4e4 | |||
| de70cbe389 | |||
| 73099aa67a | |||
| 9266f89776 | |||
| f88d7a1066 | |||
| f3e61606bd | |||
| 95794779d6 | |||
| e3f4566f6f | |||
| b86831d2ba | |||
| 4917984385 | |||
| c3b1c76151 | |||
| 7897a1555e | |||
| 523ac9f35e | |||
| 6e65b33161 | |||
| be2a9e0047 | |||
| fad2ea7763 | |||
| 4e7b6eea7b | |||
| a0704d5c7b | |||
| 63fa7c9e04 | |||
| 237e916390 | |||
| 6a16e35eb1 | |||
| 15b6abf843 | |||
| 540ace4e84 | |||
| c0a38587e2 | |||
| 2b1d9e889e | |||
| 5cd63429a9 | |||
| 820831b03d | |||
| 8cde0e7e48 | |||
| bd4651fb0b | |||
| 8d810b7a0b | |||
| c193e601f3 | |||
| 78ff45fdaf | |||
| ac42ce868a | |||
| 84ac9e51ac | |||
| 3062410b42 | |||
| beddc79892 | |||
| f7557ccbb5 | |||
| 6391853f3a | |||
| 619a844feb | |||
| e989caed33 | |||
| ad2b124a9e | |||
| 19c01e0fb4 | |||
| b38117bfae | |||
| 134f812336 | |||
| 55ab21b1bf | |||
| 853f46deb4 | |||
| ff0f855cf6 | |||
| 38b8511042 | |||
| 3c68345938 | |||
| acaec204b6 | |||
| 9b9c04498c | |||
| f13e169d10 | |||
| 2b23684dbd | |||
| 69b7ede7c4 | |||
| eb5c8fac1b | |||
| de368a1d97 | |||
| e283a3e361 | |||
| df11cc4740 | |||
| 791310a8b8 | |||
| d9c66f3786 | |||
| 2abcf3480e | |||
| fd0a4850f0 | |||
| 87bc48338d | |||
| 8811dc82bd | |||
| 51e13d5f97 | |||
| b37de0a16d | |||
| 743981b6e0 | |||
| c6a261c35d | |||
| 55a10273da | |||
| 38636d507f | |||
| 9ceb850d00 | |||
| c1d09ddcf1 | |||
| 62b5ef80ec | |||
| e54e14ba27 | |||
| 424abad60e | |||
| 6cdf81e4bb | |||
| 7bbddd90cb | |||
| c823617ff4 | |||
| 90ce6210ae | |||
| 21d6cd9922 | |||
| d80128684c | |||
| 7b453854ac | |||
| a51c7c1d86 | |||
| 9a99e9305f | |||
| 5bef729c52 | |||
| be352eec66 | |||
| 6a014d1e76 | |||
| a0744e836b | |||
| dd2eba59b2 | |||
| b7d9d1346a | |||
| ab6c93a679 | |||
| 1db40bc4cd | |||
| d237fa9524 | |||
| 630abbe8a6 | |||
| b1d8971a74 | |||
| 345102f498 | |||
| 58f76ee19d | |||
| 12ad969d0e | |||
| fe6444f2d7 | |||
| 65b515e533 | |||
| 4bf94b0486 | |||
| 450d5dc9de | |||
| c165120666 | |||
| 6339f9750a | |||
| cd27fe90e4 | |||
| c5907129f2 | |||
| fb4c5c7410 | |||
| d3861b8e04 | |||
| 1f4f21b98e | |||
| 99d1ae93e9 | |||
| 47233170e1 | |||
| 7b5ec0c79a | |||
| e197963251 | |||
| 262bc93310 | |||
| feece87c09 | |||
| f364ce9398 | |||
| 727b33c8cc | |||
| 118a7deeab | |||
| 0201f813ba | |||
| 1c7e804138 | |||
| b840174542 | |||
| 3895d87337 | |||
| e51a5d6392 | |||
| 425c4f0cb5 | |||
| 29086778ce | |||
| 9ef395d1a4 | |||
| 525b848d90 | |||
| 809ce73463 | |||
| 37a135804b | |||
| 2a69d5fb80 | |||
| 7699171345 | |||
| 8d0682d0f5 | |||
| 04e546e8b5 | |||
| fa5d3e4a41 | |||
| 622e54c4c6 | |||
| 694582fe64 | |||
| 0942b220d0 | |||
| e8bc72f27e | |||
| f17b758fdf | |||
| 7ee71ccafa | |||
| d1b3507709 | |||
| 1e215db588 | |||
| 5c81fdbb84 | |||
| 0431118366 | |||
| 5bc90019f8 | |||
| a5d192df6d | |||
| 5a24db9f12 | |||
| bec7d1ed2b | |||
| 5dccbf5a6e | |||
| 3e95d2c71b | |||
| bfcb14fe91 | |||
| cb3cd8e81a | |||
| 51e74db127 | |||
| cf110f2325 | |||
| 73dc6ed039 | |||
| 6a2cb3eaaa | |||
| 5e780eb767 | |||
| f69a4674a5 | |||
| c034a675c9 | |||
| 75d8bfb3e5 | |||
| abb510f3c9 |
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: update-int-snapshots
|
||||
description: Use when Playwright visual snapshots in e2e/visual.spec.ts-snapshots/ need to be regenerated after a UI change (logo, theme, layout, anything that affects rendered pixels) and the integration suite needs to be rerun via make int
|
||||
---
|
||||
|
||||
# Update Integration Snapshots
|
||||
|
||||
## When to Use
|
||||
|
||||
- A visual change landed (logo, color, spacing, fonts) and `make int` will fail on `visual.spec.ts` until snapshots are regenerated.
|
||||
- A snapshot test reports diffs you've confirmed are intentional.
|
||||
|
||||
Do NOT use when diffs are unintentional regressions — investigate first.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Delete the stale snapshots** so Playwright writes fresh ones (don't try to update in place, the old PNGs can confuse the diff):
|
||||
|
||||
```bash
|
||||
rm e2e/visual.spec.ts-snapshots/*.png
|
||||
```
|
||||
|
||||
2. **Check for port 8080 conflicts.** The `custom_base` test container binds host port 8080. If another container is already on it (common: `doligence-api-1`), the run fails with `Bind for 0.0.0.0:8080 failed: port is already allocated`.
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}\t{{.Ports}}' | grep ':8080->'
|
||||
```
|
||||
|
||||
If anything other than test containers is bound, stop it first (ask the user before stopping containers from other projects).
|
||||
|
||||
3. **Run with `--update-snapshots` via `compose up`.** You must use `compose up` (not `compose run`) because the navigation sidebar snapshot includes the live container list, and the two modes produce different sibling containers. The cleanest way is to patch the playwright command in `docker-compose.yml` temporarily:
|
||||
|
||||
```bash
|
||||
sed -i.bak 's|command: npx --yes playwright test|command: npx --yes playwright test --update-snapshots|' docker-compose.yml
|
||||
make int
|
||||
mv docker-compose.yml.bak docker-compose.yml
|
||||
```
|
||||
|
||||
Snapshots must be generated in Linux/Chromium (the compose image), not macOS, because filenames include the platform suffix (e.g. `-chromium-linux.png`).
|
||||
|
||||
4. **Verify** by rerunning the normal suite:
|
||||
|
||||
```bash
|
||||
make int
|
||||
```
|
||||
|
||||
Should now pass cleanly.
|
||||
|
||||
5. **Commit the regenerated PNGs** alongside the UI change so CI stays green.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Running `npx playwright test --update-snapshots` locally on macOS.** Generates `-darwin` filenames CI doesn't use. Always go through the docker compose image.
|
||||
- **Forgetting to delete first.** `--update-snapshots` does overwrite, but if the test layout changed (new test, renamed snapshot), stale PNGs are left behind. Wipe-and-regenerate is safest.
|
||||
- **Stopping unrelated containers without asking.** The port conflict is annoying but the user's other dev containers may be holding important state.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
rm e2e/visual.spec.ts-snapshots/*.png
|
||||
docker ps | grep ':8080->' # confirm port free
|
||||
sed -i.bak 's|command: npx --yes playwright test|& --update-snapshots|' docker-compose.yml
|
||||
make int
|
||||
mv docker-compose.yml.bak docker-compose.yml
|
||||
make int # verify pass
|
||||
```
|
||||
@@ -2,5 +2,12 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump",
|
||||
"ignoreDeps": []
|
||||
"minimumReleaseAge": "24 hours",
|
||||
"ignoreDeps": [],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||
"groupName": "playwright"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -36,6 +36,7 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -23,14 +23,14 @@ jobs:
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -12,16 +12,19 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
@@ -31,12 +34,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.2
|
||||
go-version: 1.26.5
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
@@ -50,16 +53,19 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
@@ -93,20 +99,20 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -124,9 +130,10 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
push: true
|
||||
sbom: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -142,13 +149,11 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,20 +18,20 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -24,14 +24,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: latest
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
+26
-25
@@ -15,18 +15,17 @@ jobs:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -37,14 +36,17 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -56,12 +58,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.5"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
@@ -77,11 +79,11 @@ jobs:
|
||||
name: Go Staticcheck
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.5"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
@@ -94,18 +96,17 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
- run: npm install --global corepack@latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -14,6 +14,23 @@ Format:
|
||||
- Include file:line references when relevant
|
||||
- Maximum ~10-15 lines per response
|
||||
|
||||
## Testing Unreleased PRs
|
||||
|
||||
When replying to a GitHub issue or discussion where the fix lives in an open PR, ask the reporter to test the pre-built image: `amir20/dozzle:pr-XXX` (XXX = PR number). CI builds a tagged image per PR, so reporters can verify without waiting for the next release.
|
||||
|
||||
## GitHub Tone (issues, PRs, comments, discussions)
|
||||
|
||||
When posting anything to GitHub, write like a human maintainer, not an AI assistant. Avoid telltale LLM patterns:
|
||||
|
||||
- No em dashes or en dashes. Use commas, periods, or parentheses instead.
|
||||
- No "Not X, but Y" rhetorical contrasts.
|
||||
- No throat-clearing openers ("Great point", "Makes sense", "Thanks for the detailed write-up").
|
||||
- No closing summaries or recap sentences.
|
||||
- No bolded inline labels mid-paragraph ("**Why:**", "**Note:**").
|
||||
- Drop hedges ("essentially", "basically", "essentially just"). Say it plain.
|
||||
- Lowercase casual tone is fine. Contractions are fine. Short sentences are fine.
|
||||
- Don't over-explain tradeoffs. State the decision, give one reason, stop.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Dozzle is a lightweight, web-based Docker log viewer with real-time monitoring capabilities. It's a hybrid application with:
|
||||
@@ -126,8 +143,8 @@ The Go backend is organized into these key packages:
|
||||
|
||||
- **`internal/support/`** - Support utilities
|
||||
- `cli/`: Command-line argument parsing and validation
|
||||
- `docker/`: Multi-host Docker management and Swarm support
|
||||
- `container/`: Container service abstractions
|
||||
- `docker/`: Multi-host Docker management and Swarm support (`docker_service.go`, client managers)
|
||||
- `k8s/`: Kubernetes service abstractions
|
||||
- `web/`: Web service utilities
|
||||
|
||||
- **`internal/auth/`** - Authentication providers
|
||||
@@ -143,10 +160,6 @@ The Go backend is organized into these key packages:
|
||||
- `log_listener.go`: Log pattern matching for alerts
|
||||
- `dispatcher/`: Notification channel implementations (email, webhook, etc.)
|
||||
|
||||
- **`graph/`** - GraphQL API layer
|
||||
- `schema.graphqls`: GraphQL schema definitions
|
||||
- `*.resolvers.go`: GraphQL resolver implementations
|
||||
|
||||
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s/agent)
|
||||
|
||||
### Frontend (Vue 3)
|
||||
@@ -207,7 +220,6 @@ The frontend uses file-based routing with these conventions:
|
||||
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`
|
||||
6. **GraphQL**: POST to `/api/graphql` for queries and mutations (container metadata, historical logs, notifications)
|
||||
|
||||
### Build System
|
||||
|
||||
@@ -237,7 +249,7 @@ The frontend uses file-based routing with these conventions:
|
||||
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
|
||||
- `chartData` is always a rolling window of max 300 items — array length stays constant
|
||||
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
|
||||
- Component instance is reused when switching containers; parent must call exposed `recalculate()` to force refresh
|
||||
- Component instance is reused when switching containers; after init the chart only patches the last bar per tick, so on a wholesale `chartData` replacement (container switch) the parent must call the exposed `recalculate()`. `MultiContainerStat` holds refs to its `BarChart`s and calls it in the `containers` watch. (Note: `Container` carries Vue `ref`s, so VueTestUtils `setProps` cannot retrigger such a watch — tests must swap the container via a parent `ref` re-render.)
|
||||
|
||||
### Backend
|
||||
|
||||
@@ -245,9 +257,6 @@ The frontend uses file-based routing with these conventions:
|
||||
- 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
|
||||
- **GraphQL API**: Uses gqlgen with schema in `graph/schema.graphqls`, generated code in `graph/generated.go`
|
||||
- Run `pnpm codegen` to regenerate GraphQL types
|
||||
- Resolvers follow-schema layout in `graph/*.resolvers.go`
|
||||
- **Service Layer Architecture**:
|
||||
- `ClientService` interface abstracts Docker/K8s/Agent backends
|
||||
- `MultiHostService` orchestrates multi-host operations
|
||||
@@ -315,7 +324,7 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
|
||||
1. Define method in `container.Client` interface (`internal/container/client.go`)
|
||||
2. Implement in `internal/docker/client.go` (and `internal/k8s/client.go` if applicable)
|
||||
3. Add wrapper method in `ClientService` interface (`internal/support/container/service.go`)
|
||||
3. Add wrapper method in `ClientService` interface (`internal/support/docker/docker_service.go`)
|
||||
4. Add HTTP handler in `internal/web/` with appropriate route
|
||||
|
||||
### Frontend Data Flow
|
||||
@@ -395,14 +404,6 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
4. Use `LogViewer.vue` component to render messages
|
||||
5. Add backend API endpoint if needed (see above)
|
||||
|
||||
### Adding a New GraphQL Query/Mutation
|
||||
|
||||
1. Define in `graph/schema.graphqls`
|
||||
2. Run `pnpm codegen` to regenerate types
|
||||
3. Implement resolver in `graph/schema.resolvers.go`
|
||||
4. Use `hostService` from resolver context to access backend services
|
||||
5. Frontend calls via urql client (auto-imported via `@urql/vue`)
|
||||
|
||||
### Adding Container Stats/Metrics
|
||||
|
||||
1. Add field to `Stat` type in `internal/container/types.go`
|
||||
@@ -424,7 +425,7 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
**Frontend** (`assets/pages/notifications.vue`, `assets/components/Notification/`):
|
||||
|
||||
- `AlertForm.vue`, `DestinationForm.vue`: UI for creating rules
|
||||
- Rules stored via GraphQL mutations
|
||||
- Rules persisted to `./data/notifications.yml` via `internal/notification/persist.go`
|
||||
- Alert state displayed in notification cards
|
||||
|
||||
**Adding a new notification channel:**
|
||||
@@ -432,7 +433,6 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
1. Implement dispatcher interface in `internal/notification/dispatcher/`
|
||||
2. Register in `manager.go` dispatcher factory
|
||||
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
|
||||
4. Add GraphQL schema fields if needed
|
||||
|
||||
### Adding a New Cloud Tool
|
||||
|
||||
@@ -465,5 +465,4 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
|
||||
- Backend logs: Set `--level debug` flag or `DOZZLE_LEVEL=debug` env var
|
||||
- Frontend: Vue DevTools browser extension
|
||||
- GraphQL: Use GraphQL Playground at `/api/graphql` (when enabled)
|
||||
- SSE streams: Browser DevTools Network tab shows EventSource connections
|
||||
|
||||
+5
-3
@@ -3,15 +3,17 @@ FROM --platform=$BUILDPLATFORM node:25.9.0-alpine AS node
|
||||
|
||||
RUN npm install -g --force corepack && corepack enable
|
||||
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install dependencies from lock file
|
||||
COPY pnpm-*.yaml ./
|
||||
RUN pnpm fetch --ignore-scripts --no-optional
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm fetch --ignore-scripts
|
||||
|
||||
# Copy package.json and install dependencies
|
||||
COPY package.json ./
|
||||
RUN pnpm install --offline --ignore-scripts --no-optional
|
||||
RUN pnpm install --offline --ignore-scripts
|
||||
|
||||
# Copy assets and translations to build
|
||||
COPY vite.config.ts tsconfig.json .prettierrc.cjs .npmrc ./
|
||||
|
||||
@@ -21,6 +21,10 @@ fake_assets:
|
||||
test: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 40s ./...
|
||||
|
||||
.PHONY: test-update
|
||||
test-update: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 5s ./... -- -- -u
|
||||
|
||||
.PHONY: build
|
||||
build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
|
||||
@@ -81,6 +81,8 @@ See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more deta
|
||||
|
||||
Dozzle uses automatic API negotiation, which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
|
||||
|
||||
Dozzle requires Docker Engine 19.03 or newer (API version 1.40+). Older daemons are not supported by the underlying Docker SDK.
|
||||
|
||||
### Installation on Podman
|
||||
|
||||
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
|
||||
@@ -143,11 +145,51 @@ There are many ways to support Dozzle:
|
||||
|
||||
## Building
|
||||
|
||||
To build and test locally:
|
||||
Want to contribute? Great! Dozzle has two parts: a **Go backend** that talks to Docker, and a **Vue frontend** that runs in the browser. You don't need to know both — pick the side that matches what you want to change. For documentation fixes, no setup is needed at all; just edit the file on GitHub.
|
||||
|
||||
1. Install [Node.js](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
|
||||
2. Install [Go](https://go.dev/doc/install).
|
||||
3. Install [protoc](https://grpc.io/docs/protoc-installation/).
|
||||
4. Install Go tools with `go install tool`.
|
||||
5. Install Node modules with `pnpm install`.
|
||||
6. Run `make dev` to start a development server with hot reload.
|
||||
### 1. Install the prerequisites
|
||||
|
||||
You'll need [Go](https://go.dev/doc/install) (1.25+), [Node.js](https://nodejs.org/en/download/) (with [pnpm](https://pnpm.io/installation)), and [protoc](https://grpc.io/docs/protoc-installation/).
|
||||
|
||||
On macOS, you can install everything in one go:
|
||||
|
||||
```bash
|
||||
brew install go node pnpm protobuf
|
||||
```
|
||||
|
||||
On Linux (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo apt install golang nodejs protobuf-compiler
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
On Windows, we recommend using [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and following the Linux instructions.
|
||||
|
||||
### 2. Clone and set up
|
||||
|
||||
```bash
|
||||
git clone https://github.com/amir20/dozzle.git
|
||||
cd dozzle
|
||||
pnpm install # installs frontend dependencies
|
||||
go install tool # installs Go build tools listed in go.mod (air, protoc-gen-go, etc.)
|
||||
make generate # generates TLS certificates and protobuf code (only needed once)
|
||||
```
|
||||
|
||||
### 3. Start the dev server
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3100](http://localhost:3100) — you should see the Dozzle UI connected to your local Docker. Both the frontend and backend reload automatically when you save a file.
|
||||
|
||||
### Making your first change
|
||||
|
||||
Try editing `assets/pages/index.vue` and saving — the browser updates instantly. For backend changes, edit any `.go` file and the server will restart on its own.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **Nothing shows up at localhost:3100** — make sure Docker is running and the socket is accessible at `/var/run/docker.sock`.
|
||||
- **`make generate` fails** — confirm `protoc` is on your PATH (`protoc --version`).
|
||||
- **Still stuck?** Open a question in [GitHub Discussions](https://github.com/amir20/dozzle/discussions) — we're happy to help.
|
||||
|
||||
Vendored
+19
-3
@@ -6,10 +6,12 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
@@ -17,6 +19,7 @@ declare global {
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const colorize: typeof import('./utils/index').colorize
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
@@ -28,6 +31,7 @@ declare global {
|
||||
const controlledRef: typeof import('@vueuse/core').controlledRef
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createContainerHints: typeof import('./composable/exprEditor').createContainerHints
|
||||
const createDisposableDirective: typeof import('@vueuse/core').createDisposableDirective
|
||||
const createDrawer: typeof import('./composable/drawer').createDrawer
|
||||
const createEventHints: typeof import('./composable/exprEditor').createEventHints
|
||||
const createEventHook: typeof import('@vueuse/core').createEventHook
|
||||
@@ -65,8 +69,10 @@ declare global {
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const getDeep: typeof import('./utils/index').getDeep
|
||||
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
|
||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||
@@ -116,6 +122,7 @@ declare global {
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
@@ -212,6 +219,7 @@ declare global {
|
||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useCommands: typeof import('./composable/commands').useCommands
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||
@@ -416,10 +424,13 @@ declare global {
|
||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||
import('./composable/cloudLogSearch')
|
||||
// @ts-ignore
|
||||
export type { CommandSection, Command } from './composable/commands'
|
||||
import('./composable/commands')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
export type { LogStreamSource } from './composable/eventStreams'
|
||||
export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
|
||||
import('./composable/eventStreams')
|
||||
// @ts-ignore
|
||||
export type { ExprEditorOptions } from './composable/exprEditor'
|
||||
@@ -434,7 +445,7 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
@@ -457,6 +468,7 @@ declare module 'vue' {
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
||||
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
|
||||
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
@@ -468,6 +480,7 @@ declare module 'vue' {
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
|
||||
readonly createDisposableDirective: UnwrapRef<typeof import('@vueuse/core')['createDisposableDirective']>
|
||||
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
|
||||
readonly createEventHints: UnwrapRef<typeof import('./composable/exprEditor')['createEventHints']>
|
||||
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
|
||||
@@ -504,8 +517,10 @@ declare module 'vue' {
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -554,6 +569,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
@@ -577,7 +593,6 @@ declare module 'vue' {
|
||||
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 scrollContextKey: UnwrapRef<typeof import('./composable/scrollContext')['scrollContextKey']>
|
||||
readonly search: UnwrapRef<typeof import('./stores/settings')['search']>
|
||||
readonly sessionHost: UnwrapRef<typeof import('./composable/storage')['sessionHost']>
|
||||
@@ -648,6 +663,7 @@ declare module 'vue' {
|
||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useCommands: UnwrapRef<typeof import('./composable/commands')['useCommands']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { build } from "vite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("production build", () => {
|
||||
it("emits font URLs relative to the generated stylesheet", async () => {
|
||||
const result = await build({
|
||||
configFile: "vite.config.ts",
|
||||
logLevel: "silent",
|
||||
build: { write: false },
|
||||
});
|
||||
const outputs = Array.isArray(result) ? result : [result];
|
||||
const stylesheet = outputs
|
||||
.flatMap((output) => ("output" in output ? output.output : []))
|
||||
.find((item) => item.type === "asset" && item.fileName.endsWith(".css"));
|
||||
|
||||
expect(stylesheet).toBeDefined();
|
||||
if (!stylesheet || stylesheet.type !== "asset") {
|
||||
throw new Error("Production build did not emit a stylesheet");
|
||||
}
|
||||
const css = String(stylesheet.source);
|
||||
expect(css).toMatch(/url\(\.\/jetbrains-mono/);
|
||||
expect(css).not.toMatch(/url\(\/assets\/jetbrains-mono/);
|
||||
}, 15_000);
|
||||
});
|
||||
Vendored
+12
-3
@@ -70,6 +70,7 @@ declare module 'vue' {
|
||||
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']
|
||||
IOCard: typeof import('./components/LogViewer/IOCard.vue')['default']
|
||||
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
|
||||
JsonFormatted: typeof import('./components/common/JsonFormatted.vue')['default']
|
||||
JsonText: typeof import('./components/common/JsonText.vue')['default']
|
||||
@@ -104,6 +105,8 @@ declare module 'vue' {
|
||||
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowCollapse': typeof import('~icons/mdi/arrow-collapse')['default']
|
||||
'Mdi:arrowExpand': typeof import('~icons/mdi/arrow-expand')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
||||
@@ -125,6 +128,8 @@ declare module 'vue' {
|
||||
'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:filterOffOutline': typeof import('~icons/mdi/filter-off-outline')['default']
|
||||
'Mdi:filterOutline': typeof import('~icons/mdi/filter-outline')['default']
|
||||
'Mdi:flash': typeof import('~icons/mdi/flash')['default']
|
||||
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
|
||||
'Mdi:github': typeof import('~icons/mdi/github')['default']
|
||||
@@ -159,17 +164,18 @@ declare module 'vue' {
|
||||
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']
|
||||
'Ph:caretRight': typeof import('~icons/ph/caret-right')['default']
|
||||
'Ph:circlesFour': typeof import('~icons/ph/circles-four')['default']
|
||||
'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:database': typeof import('~icons/ph/database')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:downloadSimple': typeof import('~icons/ph/download-simple')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'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']
|
||||
@@ -179,16 +185,18 @@ declare module 'vue' {
|
||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
|
||||
Search: typeof import('./components/Search.vue')['default']
|
||||
SearchStatus: typeof import('./components/LogViewer/SearchStatus.vue')['default']
|
||||
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
|
||||
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
SidePanel: typeof import('./components/SidePanel.vue')['default']
|
||||
'SimpleIcons:podman': typeof import('~icons/simple-icons/podman')['default']
|
||||
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
|
||||
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
|
||||
SlideTransition: typeof import('./components/common/SlideTransition.vue')['default']
|
||||
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
StatCard: typeof import('./components/LogViewer/StatCard.vue')['default']
|
||||
'SvgSpinners:ringResize': typeof import('~icons/svg-spinners/ring-resize')['default']
|
||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
@@ -197,6 +205,7 @@ declare module 'vue' {
|
||||
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
|
||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
|
||||
VolumeWarning: typeof import('./components/ContainerViewer/VolumeWarning.vue')['default']
|
||||
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
|
||||
WelcomeModal: typeof import('./components/WelcomeModal.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import BarChart, { type BarDataPoint } from "./BarChart.vue";
|
||||
|
||||
// useElementSize relies on ResizeObserver which jsdom lacks, so the width stays
|
||||
// 0 and the chart never renders. Mock it with a controllable width ref that we
|
||||
// flip to a real value after mount to mimic the ResizeObserver firing.
|
||||
const holder = vi.hoisted(() => ({ width: null as ReturnType<typeof import("vue").ref<number>> | null }));
|
||||
vi.mock("@vueuse/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vueuse/core")>();
|
||||
const { ref: vueRef } = await import("vue");
|
||||
holder.width = vueRef(0);
|
||||
return { ...actual, useElementSize: () => ({ width: holder.width, height: vueRef(0) }) };
|
||||
});
|
||||
|
||||
function ramp(start = 0, n = 300): BarDataPoint[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ percent: start + i, value: start + i }));
|
||||
}
|
||||
|
||||
function constant(percent: number, n = 300): BarDataPoint[] {
|
||||
return Array.from({ length: n }, () => ({ percent, value: percent }));
|
||||
}
|
||||
|
||||
function heightOf(wrapper: ReturnType<typeof mount>, index: number): number {
|
||||
const style = wrapper.findAll(".bar")[index]?.attributes("style") ?? "";
|
||||
const match = style.match(/--height:\s*([\d.]+)%/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
async function mountAndRender(chartData: BarDataPoint[]) {
|
||||
// Mount with an unmeasured element, then simulate ResizeObserver reporting a
|
||||
// real width -> triggers the initial recalculate, like the live component.
|
||||
holder.width!.value = 0;
|
||||
const wrapper = mount(BarChart, { props: { chartData } });
|
||||
await nextTick();
|
||||
holder.width!.value = 300;
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("<BarChart />", () => {
|
||||
test("exposed recalculate() rebuilds all bars after a wholesale data swap", async () => {
|
||||
// First container: a ramp where the oldest bars are near zero.
|
||||
const wrapper = await mountAndRender(ramp());
|
||||
expect(heightOf(wrapper, 0)).toBeLessThan(20); // oldest ramp bar is tiny
|
||||
|
||||
// A stat tick arrives: the rolling window shifts by one, marking the chart
|
||||
// initialized so further changes only patch the last bar.
|
||||
await wrapper.setProps({ chartData: ramp(1) });
|
||||
await nextTick();
|
||||
|
||||
// Switch containers: the whole series is replaced with a flat high value.
|
||||
// The chart caches bars and only patches the last one, so without help the
|
||||
// older bars stay stale.
|
||||
await wrapper.setProps({ chartData: constant(1000) });
|
||||
await nextTick();
|
||||
expect(heightOf(wrapper, 0)).toBeLessThan(20); // still stale
|
||||
|
||||
// The parent owns container switches and calls recalculate() to refresh.
|
||||
(wrapper.vm as unknown as { recalculate: () => void }).recalculate();
|
||||
await nextTick();
|
||||
expect(heightOf(wrapper, 0)).toBeGreaterThan(50); // flat series -> uniform height
|
||||
});
|
||||
|
||||
test("renders downsampled bars once width is known", async () => {
|
||||
const wrapper = await mountAndRender(constant(1000));
|
||||
expect(wrapper.findAll(".bar").length).toBeGreaterThan(0);
|
||||
expect(heightOf(wrapper, 0)).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,9 @@ watch([availableBars, bucketSize], () => {
|
||||
changeCounter.value = 0;
|
||||
});
|
||||
|
||||
// On data changes, only update the last bar unless a new bucket boundary is crossed
|
||||
// On data changes, only update the last bar unless a new bucket boundary is crossed.
|
||||
// A wholesale replacement of the series (e.g. switching containers) is not detected
|
||||
// here; the parent owns that and must call the exposed recalculate() on switch.
|
||||
const changeCounter = ref(0);
|
||||
let initialized = false;
|
||||
watch(
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
<li v-for="other in containers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }" class="text-nowrap">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
{{ other.name }}
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import ContainerStatCell from "./ContainerStatCell.vue";
|
||||
import { Container, emptyStat, type Stat } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function makeStat(partial: Partial<Stat> = {}): Stat {
|
||||
return { ...emptyStat(), ...partial };
|
||||
}
|
||||
|
||||
function makeContainer(lastStat: Partial<Stat>, cpuLimit = 0): Container {
|
||||
return new Container(
|
||||
"id-1",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"name",
|
||||
"command",
|
||||
"localhost",
|
||||
{},
|
||||
"running",
|
||||
cpuLimit,
|
||||
0,
|
||||
[makeStat(lastStat)],
|
||||
);
|
||||
}
|
||||
|
||||
function host(nCPU?: number): Host {
|
||||
return { id: "localhost", name: "localhost", nCPU } as unknown as Host;
|
||||
}
|
||||
|
||||
function mountCell(props: { container: Container; type: "cpu" | "mem"; host: Host; mode?: "chart" | "progress" }) {
|
||||
return mount(ContainerStatCell, {
|
||||
props: { mode: "progress", ...props },
|
||||
global: { stubs: { BarChart: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("<ContainerStatCell /> cpu", () => {
|
||||
test("normalizes by cpuLimit when set", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 100 }, 2), type: "cpu", host: host(8) });
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("50%");
|
||||
expect(wrapper.find("progress").attributes("value")).toBe("50");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-success");
|
||||
});
|
||||
|
||||
test("falls back to host nCPU when no cpuLimit", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 360 }, 0), type: "cpu", host: host(4) });
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("90%");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-warning");
|
||||
});
|
||||
|
||||
test("falls back to a single core and clamps at 100%", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 200 }, 0), type: "cpu", host: host(undefined) });
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("100%");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContainerStatCell /> memory", () => {
|
||||
test("shows absolute usage and percentage-based color", () => {
|
||||
const wrapper = mountCell({
|
||||
container: makeContainer({ memory: 40, memoryUsage: 1500 }),
|
||||
type: "mem",
|
||||
host: host(4),
|
||||
});
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("1.46 KB");
|
||||
expect(wrapper.find("progress").attributes("value")).toBe("40");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContainerStatCell /> color thresholds", () => {
|
||||
test.each([
|
||||
[50, "bg-success"],
|
||||
[70, "bg-secondary"],
|
||||
[90, "bg-warning"],
|
||||
[95, "bg-error"],
|
||||
])("memory %i%% -> %s", (memory, expected) => {
|
||||
const wrapper = mountCell({ container: makeContainer({ memory }), type: "mem", host: host(4) });
|
||||
expect((wrapper.vm as unknown as { barClass: string }).barClass).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContainerStatCell /> chart data", () => {
|
||||
test("cpu series is normalized by cores", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 100 }, 4), type: "cpu", host: host(8) });
|
||||
const chartData = (wrapper.vm as unknown as { chartData: { percent: number; value: number }[] }).chartData;
|
||||
expect(chartData).toHaveLength(300);
|
||||
expect(chartData.at(-1)).toEqual({ percent: 25, value: 100 });
|
||||
});
|
||||
|
||||
test("memory series uses percent and absolute usage", () => {
|
||||
const wrapper = mountCell({
|
||||
container: makeContainer({ memory: 30, memoryUsage: 2048 }),
|
||||
type: "mem",
|
||||
host: host(4),
|
||||
});
|
||||
const chartData = (wrapper.vm as unknown as { chartData: { percent: number; value: number }[] }).chartData;
|
||||
expect(chartData.at(-1)).toEqual({ percent: 30, value: 2048 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div
|
||||
v-if="isMobile"
|
||||
class="flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium tabular-nums"
|
||||
:class="type === 'cpu' ? 'bg-primary/10 text-primary' : 'bg-secondary/10 text-secondary'"
|
||||
>
|
||||
<component :is="type === 'cpu' ? PhCpu : PhMemory" class="size-3.5 shrink-0" />
|
||||
<span>{{ displayValue }}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-row items-center gap-2">
|
||||
<template v-if="mode === 'chart'">
|
||||
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
|
||||
</template>
|
||||
@@ -13,6 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Container } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const {
|
||||
container,
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
v-show="isVisible(key)"
|
||||
>
|
||||
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
|
||||
<span>{{ $t(value.label) }}</span>
|
||||
<span>{{ $t(isMobile && value.mobileLabel ? value.mobileLabel : value.label) }}</span>
|
||||
<span class="h-4" data-icon>
|
||||
<mdi:arrow-up />
|
||||
</span>
|
||||
@@ -87,10 +87,10 @@
|
||||
<tr
|
||||
v-for="container in paginated"
|
||||
:key="container.id"
|
||||
v-memo="[container.id, statMode]"
|
||||
v-memo="[container.id, statMode, isMobile]"
|
||||
class="hover:bg-base-100/80!"
|
||||
>
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate">
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate max-md:max-w-32">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
@@ -142,6 +142,7 @@ const fields: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
mobileLabel?: string;
|
||||
sortFunc: (a: Container, b: Container) => number;
|
||||
mobileVisible: boolean;
|
||||
customClass?: string;
|
||||
@@ -149,6 +150,7 @@ const fields: Record<
|
||||
> = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
mobileLabel: "label.name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
@@ -167,21 +169,23 @@ const fields: Record<
|
||||
created: {
|
||||
label: "label.created",
|
||||
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
|
||||
mobileVisible: true,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
cpu: {
|
||||
label: "label.avg-cpu",
|
||||
mobileLabel: "label.cpu",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
mobileVisible: true,
|
||||
customClass: "min-w-48 max-md:min-w-0",
|
||||
},
|
||||
mem: {
|
||||
label: "label.avg-mem",
|
||||
mobileLabel: "label.mem",
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.movingAverage.memoryUsage - b.movingAverage.memoryUsage) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
mobileVisible: true,
|
||||
customClass: "min-w-48 max-md:min-w-0",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<li v-if="!historical">
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="hasComplexLogs">
|
||||
@@ -199,7 +199,7 @@ const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
||||
|
||||
const router = useRouter();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { t } = useI18n();
|
||||
const { showToast, removeToast } = useToast();
|
||||
|
||||
@@ -299,7 +299,7 @@ async function copyLogs() {
|
||||
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blobPromise })]);
|
||||
}
|
||||
|
||||
onKeyStroke("f", (e) => {
|
||||
onKeyStroke(["f", "F"], (e) => {
|
||||
if (hasComplexLogs.value) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(LogAnalytics, { container }, "lg");
|
||||
@@ -308,14 +308,14 @@ onKeyStroke("f", (e) => {
|
||||
}
|
||||
});
|
||||
if (enableShell) {
|
||||
onKeyStroke("a", (e) => {
|
||||
onKeyStroke(["a", "A"], (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(Terminal, { container, action: "attach" }, "lg");
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke("e", (e) => {
|
||||
onKeyStroke(["e", "E"], (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(Terminal, { container, action: "exec" }, "lg");
|
||||
e.preventDefault();
|
||||
@@ -366,10 +366,25 @@ a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
/* daisyUI's .menu is width: fit-content, so nested submenus (Streams, Levels)
|
||||
* shrink to their content and the hover highlight stops short. Stretch them to
|
||||
* fill the dropdown so the row highlight spans the full width. */
|
||||
.menu li ul {
|
||||
margin-inline-start: 0;
|
||||
width: 100%;
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the solid level colors, but use white labels in the light theme so the
|
||||
* text reads against the saturated chip backgrounds. warn is a light orange,
|
||||
* where dark text has better contrast than white, so it keeps the default. */
|
||||
[data-theme="light"] .badge[data-level="info"],
|
||||
[data-theme="light"] .badge[data-level="debug"],
|
||||
[data-theme="light"] .badge[data-level="trace"],
|
||||
[data-theme="light"] .badge[data-level="error"],
|
||||
[data-theme="light"] .badge[data-level="fatal"] {
|
||||
color: oklch(100% 0 0) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
|
||||
{{ container.hostLabel }}
|
||||
</li>
|
||||
<li>
|
||||
<template v-if="otherContainers.length === 0">{{ container.name }}</template>
|
||||
<li class="min-w-0">
|
||||
<template v-if="otherContainers.length === 0"
|
||||
><span class="block truncate">{{ container.name }}</span></template
|
||||
>
|
||||
<div v-else>
|
||||
<div class="dropdown">
|
||||
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
|
||||
@@ -25,12 +27,13 @@
|
||||
<li v-for="other in otherContainers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div v-else>{{ other.name }}</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
@@ -42,8 +45,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
<VolumeWarning :container="container" />
|
||||
<Tag
|
||||
class="group hidden! cursor-pointer items-center gap-1.5 pr-1! font-mono @md:inline-flex!"
|
||||
size="small"
|
||||
role="button"
|
||||
:title="$t('toolbar.copy-image')"
|
||||
:aria-label="$t('toolbar.copy-image')"
|
||||
@click="copyImage"
|
||||
>
|
||||
<span class="truncate">{{ imageTag }}</span>
|
||||
<span
|
||||
class="bg-base-content/10 text-base-content/40 group-hover:text-base-content/70 flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<mdi:content-copy class="size-3" />
|
||||
</span>
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,6 +68,21 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { showToast } = useToast();
|
||||
|
||||
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
|
||||
|
||||
async function copyImage() {
|
||||
if (!isSupported.value) return;
|
||||
await copy(imageTag.value);
|
||||
if (copied.value) {
|
||||
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.name),
|
||||
set: (value) => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div v-if="worst" class="dropdown dropdown-end" :class="{ 'dropdown-bottom': !openUp }">
|
||||
<button
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium tabular-nums"
|
||||
:class="badgeClass"
|
||||
:title="title"
|
||||
>
|
||||
<PhWarning class="size-3.5" />
|
||||
<span class="max-md:hidden">{{ worst.destination }}</span>
|
||||
<span>{{ formatPct(worst.pct) }}</span>
|
||||
</button>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 mt-1 w-72 border p-2 text-xs shadow-sm"
|
||||
>
|
||||
<div class="text-base-content/60 mb-1.5 px-1 text-[11px] tracking-wide uppercase">{{ t("tooltip.volumes") }}</div>
|
||||
<ul class="space-y-1.5">
|
||||
<li
|
||||
v-for="m in sortedMounts"
|
||||
:key="m.destination"
|
||||
class="flex flex-col gap-1 rounded p-1.5"
|
||||
:class="rowClass(m.pct, m.available)"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="truncate font-mono text-[11.5px]" :title="m.destination">{{ m.destination }}</span>
|
||||
<span v-if="m.available" class="tabular-nums">{{ formatPct(m.pct) }}</span>
|
||||
<span v-else class="text-base-content/50">n/a</span>
|
||||
</div>
|
||||
<div v-if="m.available" class="bg-base-content/10 h-1 w-full overflow-hidden rounded">
|
||||
<div class="h-full" :class="barClass(m.pct)" :style="{ width: Math.min(100, m.pct * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="text-base-content/60 flex justify-between tabular-nums">
|
||||
<span v-if="m.available">{{ formatBytes(m.used) }} / {{ formatBytes(m.total) }}</span>
|
||||
<span v-else>{{ t("tooltip.volume-unreachable") }}</span>
|
||||
<RelativeTime v-if="m.lastChecked" :date="m.lastChecked" class="text-[10.5px]" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import PhWarning from "~icons/ph/warning-fill";
|
||||
|
||||
const WARN = 0.85;
|
||||
const CRITICAL = 0.95;
|
||||
|
||||
const { container } = defineProps<{ container: Container; openUp?: boolean }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface DerivedMount {
|
||||
destination: string;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
available: boolean;
|
||||
pct: number;
|
||||
lastChecked?: Date;
|
||||
}
|
||||
|
||||
const mounts = computed<DerivedMount[]>(() => {
|
||||
const raw = container.mountStats ?? {};
|
||||
return Object.values(raw).map((m) => ({
|
||||
destination: m.destination,
|
||||
total: m.total,
|
||||
used: m.used,
|
||||
free: m.free,
|
||||
available: m.available && m.total > 0,
|
||||
pct: m.available && m.total > 0 ? m.used / m.total : 0,
|
||||
lastChecked: m.lastChecked ? new Date(m.lastChecked) : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
const sortedMounts = computed(() => [...mounts.value].sort((a, b) => b.pct - a.pct));
|
||||
|
||||
const worst = computed(() => {
|
||||
const candidate = sortedMounts.value.find((m) => m.available && m.pct >= WARN);
|
||||
return candidate ?? null;
|
||||
});
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
if (!worst.value) return "";
|
||||
if (worst.value.pct >= CRITICAL) return "bg-error/15 text-error hover:bg-error/25";
|
||||
return "bg-warning/15 text-warning hover:bg-warning/25";
|
||||
});
|
||||
|
||||
function rowClass(pct: number, available: boolean) {
|
||||
if (!available) return "bg-base-content/[0.04]";
|
||||
if (pct >= CRITICAL) return "bg-error/10";
|
||||
if (pct >= WARN) return "bg-warning/10";
|
||||
return "bg-base-content/[0.04]";
|
||||
}
|
||||
|
||||
function barClass(pct: number) {
|
||||
if (pct >= CRITICAL) return "bg-error";
|
||||
if (pct >= WARN) return "bg-warning";
|
||||
return "bg-success";
|
||||
}
|
||||
|
||||
function formatPct(pct: number) {
|
||||
return `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (!worst.value) return "";
|
||||
return t("tooltip.volume-full", { destination: worst.value.destination, pct: formatPct(worst.value.pct) });
|
||||
});
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
|
||||
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
||||
|
||||
import { Container } from "@/models/Container";
|
||||
import { lightTheme } from "@/stores/settings";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -16,7 +17,7 @@ vi.mock("vue-router");
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
@@ -119,4 +120,28 @@ describe("<FuzzySearchModal />", () => {
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
||||
});
|
||||
|
||||
test("matches commands by keyword", async () => {
|
||||
const wrapper = createFuzzySearchModal();
|
||||
await wrapper.find("input").setValue("theme");
|
||||
const items = wrapper.findAll("li").map((li) => li.text());
|
||||
expect(items).toContain("command-palette.theme-dark");
|
||||
});
|
||||
|
||||
test("theme commands set the theme explicitly", async () => {
|
||||
lightTheme.value = "auto";
|
||||
const wrapper = createFuzzySearchModal();
|
||||
|
||||
await wrapper.find("input").setValue("dark theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("dark");
|
||||
|
||||
await wrapper.find("input").setValue("light theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("light");
|
||||
|
||||
await wrapper.find("input").setValue("system theme");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(lightTheme.value).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
tabindex="0"
|
||||
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
||||
ref="input"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, totalCount - 1)"
|
||||
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||
@keydown.enter.exact="onEnter"
|
||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
||||
@keydown.alt.enter.exact.prevent="onPin"
|
||||
v-model="query"
|
||||
:placeholder="placeholderCopy"
|
||||
/>
|
||||
@@ -29,51 +29,80 @@
|
||||
|
||||
<!-- Body: results + log search CTA. Only renders when there is something
|
||||
to show — keeps the empty modal compact. -->
|
||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Containers section -->
|
||||
<template v-if="results.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
<div v-if="totalCount || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Scroll container spans both sections so the flat Commands + Containers
|
||||
list scrolls as one, matching the unified selection index. -->
|
||||
<div class="max-h-[50vh] overflow-y-auto overscroll-contain">
|
||||
<!-- Commands section -->
|
||||
<template v-if="commandEntries.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("command-palette.section-commands") }} · {{ commandEntries.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(command, index) in commandEntries" :ref="(el) => setItemRef(el, index)">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="runCommand(command)"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<component :is="command.icon" class="text-base-content/60 size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ command.title }}</span>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="text-base-content/40 size-4" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Containers section -->
|
||||
<template v-if="containerEntries.length">
|
||||
<div
|
||||
class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase"
|
||||
:class="{ 'border-base-content/10 mt-1 border-t': commandEntries.length }"
|
||||
>
|
||||
{{ $t("cloud-search.containers-section") }} · {{ containerEntries.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li
|
||||
v-for="(result, index) in containerEntries"
|
||||
:ref="(el) => setItemRef(el, commandEntries.length + index)"
|
||||
>
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': commandEntries.length + index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="commandEntries.length + index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Log search CTA -->
|
||||
<div
|
||||
v-if="logSearchVisible"
|
||||
@@ -130,7 +159,7 @@
|
||||
<div
|
||||
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
|
||||
>
|
||||
<span v-if="results.length" class="flex items-center gap-1.5">
|
||||
<span v-if="totalCount" class="flex items-center gap-1.5">
|
||||
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
||||
</span>
|
||||
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
||||
@@ -164,6 +193,7 @@ import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
import { type FuseResult } from "fuse.js";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||
import { useCommands, type Command } from "@/composable/commands";
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
@@ -176,9 +206,15 @@ const route = useRoute();
|
||||
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
||||
const query = ref(initialQuery);
|
||||
const input = ref<HTMLInputElement>();
|
||||
const listItems = ref<HTMLInputElement[]>();
|
||||
const listItems = ref<(Element | null)[]>([]);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
// Function ref into a single flat array so Commands and Containers share one
|
||||
// selection index for arrow-key navigation and scroll-into-view.
|
||||
function setItemRef(el: any, index: number) {
|
||||
listItems.value[index] = (el?.$el ?? el) as Element | null;
|
||||
}
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
@@ -268,6 +304,20 @@ const { results: fuseResults } = useFuse(query, list, {
|
||||
|
||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
||||
|
||||
// Commands palette. Fuzzy-matched against title/keywords while typing; the
|
||||
// context commands (container actions) show up front on an empty query.
|
||||
const { commands, contextCommands } = useCommands();
|
||||
const { results: commandFuseResults } = useFuse(query, commands, {
|
||||
fuseOptions: {
|
||||
keys: ["title", "keywords"],
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
},
|
||||
});
|
||||
const commandEntries = computed<Command[]>(() =>
|
||||
query.value ? commandFuseResults.value.map((r) => r.item) : contextCommands.value,
|
||||
);
|
||||
|
||||
const data = computed(() => {
|
||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||
if (a.score === b.score) {
|
||||
@@ -284,14 +334,26 @@ const data = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
watch(query, (data) => {
|
||||
if (data.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
// Container hits, mirrors the previously named `data` list for the template.
|
||||
const containerEntries = computed(() => data.value);
|
||||
const totalCount = computed(() => commandEntries.value.length + containerEntries.value.length);
|
||||
|
||||
// Reset to the top only when the user types. Live SSE container add/remove
|
||||
// changes totalCount too, and resetting on that would snap the selection back
|
||||
// to 0 while the palette is open.
|
||||
watch(query, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
// Keep the selection in bounds when the result count shrinks underneath it.
|
||||
watch(totalCount, (count) => {
|
||||
if (selectedIndex.value > count - 1) {
|
||||
selectedIndex.value = Math.max(count - 1, 0);
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
||||
listItems.value?.[selectedIndex.value]?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
function selected(item: Item) {
|
||||
@@ -305,17 +367,35 @@ function selected(item: Item) {
|
||||
close();
|
||||
}
|
||||
|
||||
async function runCommand(command: Command) {
|
||||
close();
|
||||
await command.perform();
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
// Plain Enter prefers a container match if one is selected. With no
|
||||
// container matches (cloud-only query like "OOM"), fall back to log search
|
||||
// so the user isn't stuck on a popup that does nothing.
|
||||
if (data.value.length > 0) {
|
||||
selected(data.value[selectedIndex.value].item);
|
||||
// Commands come first in the flat list, then containers. With nothing
|
||||
// selectable (cloud-only query like "OOM"), fall back to log search so the
|
||||
// user isn't stuck on a popup that does nothing.
|
||||
const commandCount = commandEntries.value.length;
|
||||
if (selectedIndex.value < commandCount) {
|
||||
runCommand(commandEntries.value[selectedIndex.value]);
|
||||
} else if (containerEntries.value.length > 0) {
|
||||
selected(containerEntries.value[selectedIndex.value - commandCount].item);
|
||||
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
||||
runLogSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function onPin() {
|
||||
// Alt+Enter pins a container column. Only meaningful when a container row is
|
||||
// selected, not a command.
|
||||
const commandCount = commandEntries.value.length;
|
||||
if (selectedIndex.value >= commandCount) {
|
||||
const entry = containerEntries.value[selectedIndex.value - commandCount];
|
||||
if (entry?.item.type === "container") addColumn(entry.item);
|
||||
}
|
||||
}
|
||||
|
||||
function runLogSearch() {
|
||||
if (!cloudSearch.available.value) return;
|
||||
const q = query.value.trim();
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<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>
|
||||
<div class="card-body flex gap-3 max-md:p-4">
|
||||
<div class="flex flex-row items-center gap-3 overflow-hidden">
|
||||
<div class="flex min-w-0 items-center gap-2 text-lg font-semibold tracking-tight md:text-xl">
|
||||
<HostIcon :type="host.type" class="text-base-content/80 flex-none" />
|
||||
<div class="truncate">{{ host.name }}</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
|
||||
<span class="badge badge-error badge-xs gap-1 p-2 font-normal" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-2 p-2"
|
||||
class="badge badge-success badge-xs gap-1 p-2 font-normal"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
@@ -21,12 +19,19 @@
|
||||
{{ 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" />
|
||||
|
||||
<ul
|
||||
class="text-base-content/60 ml-auto flex shrink-0 flex-row flex-wrap items-center gap-x-3 text-xs tabular-nums md:text-sm"
|
||||
>
|
||||
<li class="flex items-center gap-1.5">
|
||||
<octicon:container-24 class="size-3.5" />
|
||||
{{ $t("label.container", hostContainers.length) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
<li class="flex items-center gap-1.5" :title="runtimeLabel">
|
||||
<simple-icons:podman v-if="host.runtime === 'podman'" class="size-3.5" />
|
||||
<mdi:docker v-else class="size-3.5" />
|
||||
{{ host.dockerVersion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +40,7 @@
|
||||
:icon="PhCpu"
|
||||
:value="stats.weighted.movingAverage.totalCPU"
|
||||
:chartData="cpuHistory"
|
||||
container-class="border-primary/40 bg-primary/20"
|
||||
container-class="bg-primary/10"
|
||||
text-class="text-primary"
|
||||
bar-class="bg-primary"
|
||||
:formatValue="(value) => `${value.toFixed(1)}%`"
|
||||
@@ -46,7 +51,7 @@
|
||||
:icon="PhMemory"
|
||||
:value="stats.weighted.movingAverage.totalMemUsage"
|
||||
:chartData="memHistory"
|
||||
container-class="border-secondary/40 bg-secondary/20"
|
||||
container-class="bg-secondary/10"
|
||||
text-class="text-secondary"
|
||||
bar-class="bg-secondary"
|
||||
:formatValue="(value) => formatBytes(value, { decimals: 1 })"
|
||||
@@ -60,9 +65,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Host } from "@/stores/hosts";
|
||||
import { Container } from "@/models/Container";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -78,6 +81,8 @@ const hostContainers = computed(() =>
|
||||
containers.value.filter((container) => container.host === props.host.id && container.state === "running"),
|
||||
);
|
||||
|
||||
const runtimeLabel = computed(() => (props.host.runtime === "podman" ? "Podman" : "Docker"));
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return container.cpuLimit;
|
||||
|
||||
@@ -54,10 +54,11 @@
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -89,10 +90,11 @@
|
||||
<li v-for="host in groupHosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -103,10 +105,11 @@
|
||||
<li v-for="host in groupHosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -147,12 +150,12 @@
|
||||
active-class="menu-active"
|
||||
@click.alt.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
:title="item.name"
|
||||
class="group auto-cols-[content_max_auto_max-content_max-content]"
|
||||
class="group auto-cols-[max-content_minmax(0,1fr)_max-content_max-content]"
|
||||
>
|
||||
<svg-spinners:ring-resize v-if="item.isNew" class="text-secondary w-2" />
|
||||
<div
|
||||
v-else
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="item.state"
|
||||
></div>
|
||||
<div class="truncate">
|
||||
@@ -186,11 +189,8 @@ import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
import { showAllContainers, groupContainers } from "@/stores/settings";
|
||||
|
||||
// @ts-ignore
|
||||
import Pin from "~icons/ph/map-pin-simple";
|
||||
// @ts-ignore
|
||||
import Stack from "~icons/ph/stack";
|
||||
// @ts-ignore
|
||||
import Containers from "~icons/octicon/container-24";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
@@ -78,8 +78,8 @@
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in owners" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
@@ -95,8 +95,8 @@
|
||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="owner.key">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<mdi:cog class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<dropdown class="dropdown-end" v-if="config.user">
|
||||
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
||||
<template #trigger>
|
||||
<template v-if="config.disableAvatars || !config.user.email">
|
||||
<material-symbols:person class="size-6" />
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import ComplexLogItem from "./ComplexLogItem.vue";
|
||||
import { ComplexLogEntry, type JSONObject } from "@/models/LogEntry";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function mountItem(message: JSONObject, visibleKeys?: ReturnType<typeof ref<Map<string[], boolean>>>) {
|
||||
const entry = new ComplexLogEntry(message, "c1", 1, new Date(), "info", "stdout", "raw", visibleKeys as any);
|
||||
return mount(ComplexLogItem, {
|
||||
props: { logEntry: entry },
|
||||
global: {
|
||||
// LogItem pulls in stores/hosts; we only care about the payload rendering.
|
||||
stubs: { LogItem: { template: "<div><slot /></div>" }, LogLevel: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("<ComplexLogItem />", () => {
|
||||
test("renders key=value pairs", () => {
|
||||
const wrapper = mountItem({ foo: "bar", n: 1 });
|
||||
expect(wrapper.findAll("li")).toHaveLength(2);
|
||||
expect(wrapper.text()).toContain("foo=");
|
||||
expect(wrapper.text()).toContain("bar");
|
||||
expect(wrapper.text()).toContain("n=");
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
test("renders null values as <null>", () => {
|
||||
const wrapper = mountItem({ x: null } as any);
|
||||
expect(wrapper.text()).toContain("<null>");
|
||||
});
|
||||
|
||||
test("filters out undefined values", () => {
|
||||
const wrapper = mountItem({ a: undefined as any, b: 2 });
|
||||
expect(wrapper.findAll("li")).toHaveLength(1);
|
||||
expect(wrapper.text()).toContain("b=");
|
||||
expect(wrapper.text()).not.toContain("a=");
|
||||
});
|
||||
|
||||
test("renders array values as a list", () => {
|
||||
const wrapper = mountItem({ tags: ["a", "b"] });
|
||||
expect(wrapper.find(".array").exists()).toBe(true);
|
||||
expect(wrapper.find(".array").text()).toContain("a");
|
||||
expect(wrapper.find(".array").text()).toContain("b");
|
||||
});
|
||||
|
||||
test("renders null inside an array without throwing", () => {
|
||||
const wrapper = mountItem({ tags: ["a", null, "b"] } as any);
|
||||
expect(wrapper.find(".array").text()).toContain("null");
|
||||
});
|
||||
|
||||
test("shows a placeholder when every value is hidden", () => {
|
||||
const visibleKeys = ref(new Map<string[], boolean>([[["a"], false]]));
|
||||
const wrapper = mountItem({ a: 1 }, visibleKeys);
|
||||
expect(wrapper.text()).toContain("all values are hidden");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="key">{{ name }}=</span>
|
||||
<span class="value" v-if="value === null"><null></span>
|
||||
<ReuseTemplate :data="value" v-else-if="isObject(value) || Array.isArray(value)" />
|
||||
<span v-else class="value" :class="typeof value" v-html="stripAnsi(value.toString())"></span>
|
||||
<span v-else class="value" :class="typeof value" v-html="stripAnsi(String(value))"></span>
|
||||
</li>
|
||||
<li v-else-if="Array.isArray(data)">
|
||||
<ul class="array inline-flex flex-wrap space-x-1">
|
||||
@@ -15,7 +15,7 @@
|
||||
class="after:text-base-content/70 not-last:after:content-[',']"
|
||||
>
|
||||
<ReuseTemplate :data="item" v-if="isObject(item) || Array.isArray(item)" />
|
||||
<span v-else class="value" :class="typeof item" v-html="stripAnsi(item.toString())"></span>
|
||||
<span v-else class="value" :class="typeof item" v-html="stripAnsi(String(item))"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { default as Component } from "./EventSource.vue";
|
||||
import SearchStatus from "./SearchStatus.vue";
|
||||
import LogViewer from "@/components/LogViewer/LogViewer.vue";
|
||||
import { Container } from "@/models/Container";
|
||||
import { Level } from "@/models/LogEntry";
|
||||
@@ -172,6 +173,32 @@ describe("<ContainerEventSource />", () => {
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("search status", () => {
|
||||
test("shows no-logs when not searching and the stream is empty", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("suppresses no-logs while a search is still running", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emit("search-status", {
|
||||
data: JSON.stringify({ scannedTo: "2026-06-01T14:31:00Z", matches: 0, done: false }),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(false);
|
||||
expect(wrapper.findComponent(SearchStatus).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
|
||||
<SearchStatus :status="searchStatus" class="sticky top-0 z-10" />
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog && !inSearch)">
|
||||
<div class="flex flex-row gap-2" v-for="size in sizes">
|
||||
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
|
||||
<div class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ul>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">
|
||||
<div v-else-if="noLogs && !waitingForMoreLog && !inSearch" class="p-4" data-testid="no-logs">
|
||||
{{ $t("label.no-logs") }}
|
||||
</div>
|
||||
<slot :messages="messages" v-else></slot>
|
||||
@@ -24,7 +25,11 @@ const { entity, streamSource } = $defineProps<{
|
||||
|
||||
const { historical } = useLoggingContext();
|
||||
|
||||
const { messages, opened, loading, error } = streamSource(toRef(() => entity));
|
||||
const { messages, opened, loading, error, searchStatus } = streamSource(toRef(() => entity));
|
||||
|
||||
// While a search is running (or just finished), SearchStatus owns the empty
|
||||
// messaging, so suppress the generic "no logs" state to avoid the false signal.
|
||||
const inSearch = computed(() => searchStatus.value.active || searchStatus.value.done);
|
||||
|
||||
const color = computed(() => {
|
||||
if (error.value) return "error";
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: GroupedLogEntry;
|
||||
@@ -32,6 +25,4 @@ const getPosition = (index: number): Position => {
|
||||
if (index === len - 1) return "end";
|
||||
return "middle";
|
||||
};
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-base-content/[0.06] grid min-w-0 grid-cols-[auto_auto_3.5rem_auto_3.5rem] items-center gap-x-1.5 gap-y-1 rounded-md px-2.5 py-1.5 text-[12.5px] leading-none tabular-nums max-md:hidden @max-5xl:hidden"
|
||||
:title="tooltip"
|
||||
>
|
||||
<PhNetwork class="text-base-content/60 size-3.5" />
|
||||
<PhArrowUp class="text-primary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(networkTx, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(networkRx, { short: true, decimals: 1 }) }}/s</span>
|
||||
|
||||
<PhHardDrives class="text-base-content/60 size-3.5" />
|
||||
<PhArrowUp class="text-primary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(diskWrite, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(diskRead, { short: true, decimals: 1 }) }}/s</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PhNetwork from "~icons/ph/network";
|
||||
import PhHardDrives from "~icons/ph/hard-drives";
|
||||
import PhArrowUp from "~icons/ph/arrow-up";
|
||||
import PhArrowDown from "~icons/ph/arrow-down";
|
||||
|
||||
const { networkRx, networkTx, diskRead, diskWrite } = defineProps<{
|
||||
networkRx: number;
|
||||
networkTx: number;
|
||||
diskRead: number;
|
||||
diskWrite: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const tooltip = computed(
|
||||
() =>
|
||||
t("tooltip.network-io", { tx: formatBytes(networkTx), rx: formatBytes(networkRx) }) +
|
||||
"\n" +
|
||||
t("tooltip.disk-io", { write: formatBytes(diskWrite), read: formatBytes(diskRead) }),
|
||||
);
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
@mouseenter="checkDropdownPosition"
|
||||
>
|
||||
<router-link
|
||||
v-if="isSearching"
|
||||
v-if="isFiltered"
|
||||
@click="resetSearch()"
|
||||
tabindex="0"
|
||||
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
|
||||
@@ -31,7 +31,7 @@
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="isSearching">
|
||||
<li v-if="isFiltered">
|
||||
<router-link
|
||||
@click="resetSearch()"
|
||||
:to="{
|
||||
@@ -99,8 +99,13 @@ const { showToast } = useToast();
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
const { isSearching, resetSearch } = useSearchFilter();
|
||||
const { levels } = useLoggingContext();
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
// Show "see in context" whenever the stream is narrowed, either by a text search
|
||||
// or by a log-level filter, so the entry can be inspected in the full log stream.
|
||||
const isFiltered = computed(() => isSearching.value || allLevels.some((level) => !levels.value.has(level)));
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard({ legacy: true });
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessage() {
|
||||
|
||||
@@ -1,37 +1,111 @@
|
||||
<template>
|
||||
<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>
|
||||
<aside class="flex flex-col gap-5 pb-8">
|
||||
<header class="flex items-center gap-3 pr-8">
|
||||
<ph:file-sql class="text-primary size-7 shrink-0" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<h1 class="text-xl leading-tight font-semibold">{{ $t("analytics.title") }}</h1>
|
||||
<p class="text-base-content/60 flex items-center gap-1.5 text-sm">
|
||||
<span class="truncate">{{ container.name }}</span>
|
||||
<span class="opacity-40">·</span>
|
||||
<RelativeTime :date="container.created" />
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-2">
|
||||
<section>
|
||||
<label class="form-control">
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="textarea textarea-primary w-full font-mono text-lg"
|
||||
:class="{ 'textarea-error!': error }"
|
||||
:disabled="state === 'downloading'"
|
||||
></textarea>
|
||||
<div class="mt-2">
|
||||
<span class="text-error" v-if="error">{{ error }}</span>
|
||||
<span v-else-if="state === 'initializing'">{{ $t("analytics.creating_table") }}</span>
|
||||
<span v-else-if="state === 'downloading'">{{
|
||||
$t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) })
|
||||
}}</span>
|
||||
<span v-else-if="evaluating">{{ $t("analytics.evaluating_query") }}</span>
|
||||
<span v-else>
|
||||
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
|
||||
<template v-if="results.numRows > pageLimit">{{
|
||||
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
|
||||
}}</template>
|
||||
</span>
|
||||
<section class="flex flex-col gap-2">
|
||||
<textarea
|
||||
ref="queryEl"
|
||||
v-model="query"
|
||||
class="textarea textarea-primary w-full resize-y font-mono text-sm leading-relaxed"
|
||||
:class="{ 'textarea-error!': error }"
|
||||
:disabled="state !== 'ready'"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
:aria-label="$t('analytics.title')"
|
||||
@keydown.meta.enter.prevent="run"
|
||||
@keydown.ctrl.enter.prevent="run"
|
||||
></textarea>
|
||||
|
||||
<div class="flex min-h-6 items-center text-sm">
|
||||
<div class="min-w-0 flex-1 truncate">
|
||||
<span class="text-error" v-if="error">{{ error }}</span>
|
||||
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'initializing'">
|
||||
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.creating_table") }}
|
||||
</span>
|
||||
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'downloading'">
|
||||
<span class="loading loading-spinner loading-xs"></span
|
||||
>{{ $t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) }) }}
|
||||
</span>
|
||||
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="evaluating">
|
||||
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.evaluating_query") }}
|
||||
</span>
|
||||
<span class="text-base-content/60" v-else>
|
||||
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
|
||||
<template v-if="results.numRows > pageLimit">{{
|
||||
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
|
||||
}}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end shrink-0" v-if="canExport">
|
||||
<div tabindex="0" role="button" class="btn btn-xs btn-ghost cursor-pointer gap-1">
|
||||
<ph:download-simple class="size-4" />
|
||||
{{ $t("analytics.export") }}
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-30 w-44 p-2 shadow-sm">
|
||||
<li>
|
||||
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('csv')">{{
|
||||
$t("analytics.export_csv")
|
||||
}}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('json')">{{
|
||||
$t("analytics.export_json")
|
||||
}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="state === 'ready' && columns.length" class="flex flex-col gap-2 text-xs">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="text-base-content/50 font-medium">{{ $t("analytics.examples") }}</span>
|
||||
<button
|
||||
v-for="ex in examples"
|
||||
:key="ex.key"
|
||||
class="badge badge-sm badge-outline hover:border-primary hover:text-primary cursor-pointer"
|
||||
@click="applyExample(ex.sql)"
|
||||
>
|
||||
{{ $t(ex.key, ex.params ?? {}) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary
|
||||
class="text-base-content/50 hover:text-base-content/80 flex w-fit cursor-pointer items-center gap-1 font-medium select-none"
|
||||
>
|
||||
<ph:caret-right class="size-3 transition-transform group-open:rotate-90" />
|
||||
{{ $t("analytics.columns") }}
|
||||
<span class="opacity-60">{{ columns.length }}</span>
|
||||
</summary>
|
||||
<div class="mt-2 flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
|
||||
<button
|
||||
v-for="col in columns"
|
||||
:key="col.name"
|
||||
class="badge badge-sm badge-ghost hover:border-primary hover:text-primary cursor-pointer font-mono"
|
||||
:title="col.type"
|
||||
@click="insertColumn(col.name)"
|
||||
>
|
||||
{{ col.name }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -42,11 +116,15 @@ import { type Table } from "@apache-arrow/esnext-esm";
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
const query = ref("SELECT * FROM logs LIMIT 100");
|
||||
const error = ref<string | null>(null);
|
||||
const debouncedQuery = debouncedRef(query, 500);
|
||||
const evaluating = ref(false);
|
||||
const pageLimit = 1000;
|
||||
const state = ref<"downloading" | "ready" | "initializing">("downloading");
|
||||
const bytes = ref(0);
|
||||
const columns = ref<{ name: string; type: string }[]>([]);
|
||||
const queryEl = useTemplateRef<HTMLTextAreaElement>("queryEl");
|
||||
|
||||
const runQuery = ref(query.value);
|
||||
watchDebounced(query, (v) => (runQuery.value = v), { debounce: 500 });
|
||||
|
||||
const url = withBase(
|
||||
`/api/hosts/${container.host}/containers/${container.id}/logs?stdout=1&stderr=1&everything&jsonOnly`,
|
||||
@@ -90,9 +168,15 @@ onMounted(async () => {
|
||||
|
||||
state.value = "initializing";
|
||||
await conn.query(
|
||||
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited')`,
|
||||
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`,
|
||||
);
|
||||
|
||||
const described = await conn.query<{ column_name: any; column_type: any }>(`DESCRIBE logs`);
|
||||
columns.value = described.toArray().map((row) => ({
|
||||
name: String(row.column_name),
|
||||
type: String(row.column_type),
|
||||
}));
|
||||
|
||||
state.value = "ready";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -102,10 +186,54 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const examples = computed(() => {
|
||||
const names = columns.value.map((c) => c.name);
|
||||
const pick = ["level", "severity", "lvl", "status"].find((c) => names.includes(c)) ?? names[0];
|
||||
const list: { key: string; sql: string; params?: Record<string, string> }[] = [
|
||||
{ key: "analytics.example_all", sql: "SELECT * FROM logs LIMIT 100" },
|
||||
{ key: "analytics.example_count", sql: "SELECT count(*) AS total FROM logs" },
|
||||
];
|
||||
if (pick) {
|
||||
list.push({
|
||||
key: "analytics.example_group",
|
||||
params: { column: pick },
|
||||
sql: `SELECT "${pick}", count(*) AS count FROM logs GROUP BY "${pick}" ORDER BY count DESC`,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function run() {
|
||||
if (state.value !== "ready") return;
|
||||
runQuery.value = query.value;
|
||||
}
|
||||
|
||||
function applyExample(sql: string) {
|
||||
query.value = sql;
|
||||
nextTick(run);
|
||||
}
|
||||
|
||||
function insertColumn(name: string) {
|
||||
const text = `"${name}"`;
|
||||
const el = queryEl.value;
|
||||
if (!el) {
|
||||
query.value += text;
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? query.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
query.value = query.value.slice(0, start) + text + query.value.slice(end);
|
||||
nextTick(() => {
|
||||
el.focus();
|
||||
const pos = start + text.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
const results = computedAsync(
|
||||
async () => {
|
||||
if (state.value === "ready") {
|
||||
return await conn.query<Record<string, any>>(debouncedQuery.value);
|
||||
return await conn.query<Record<string, any>>(runQuery.value);
|
||||
} else {
|
||||
return empty;
|
||||
}
|
||||
@@ -129,5 +257,54 @@ whenever(evaluating, () => {
|
||||
const page = computed(() =>
|
||||
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
||||
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
||||
|
||||
const canExport = computed(() => state.value === "ready" && !evaluating.value && results.value.numRows > 0);
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "object") return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toCSV(table: Table<Record<string, any>>, columns: string[]): string {
|
||||
const escape = (value: string) => (/[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
|
||||
const lines = [columns.map(escape).join(",")];
|
||||
for (const row of table) {
|
||||
lines.push(columns.map((column) => escape(stringify((row as Record<string, any>)[column]))).join(","));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toJSON(table: Table<Record<string, any>>, columns: string[]): string {
|
||||
const rows = [];
|
||||
for (const row of table) {
|
||||
const record: Record<string, unknown> = {};
|
||||
for (const column of columns) {
|
||||
const value = (row as Record<string, any>)[column];
|
||||
record[column] = typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
rows.push(record);
|
||||
}
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}
|
||||
|
||||
function exportResults(format: "csv" | "json") {
|
||||
const table = results.value as unknown as Table<Record<string, any>>;
|
||||
if (table.numRows === 0) return;
|
||||
|
||||
const columns = Object.keys(table.get(0) as Record<string, any>);
|
||||
const content = format === "csv" ? toCSV(table, columns) : toJSON(table, columns);
|
||||
const type = format === "csv" ? "text/csv;charset=utf-8" : "application/json";
|
||||
const name = container.name.replace(/[^\w.-]+/g, "-");
|
||||
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${name}-query.${format}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<li>
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="enableDownload">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { defineComponent, h, nextTick, ref } from "vue";
|
||||
import { Container, Stat } from "@/models/Container";
|
||||
import MultiContainerStat from "./MultiContainerStat.vue";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { hosts: [], base: "" },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
// Capture recalculate() across both chart instances.
|
||||
const recalculate = vi.fn();
|
||||
const BarChartStub = defineComponent({
|
||||
name: "BarChart",
|
||||
props: ["chartData", "barClass"],
|
||||
setup(_, { expose }) {
|
||||
expose({ recalculate });
|
||||
return () => h("div", { class: "bar-chart-stub" });
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } });
|
||||
|
||||
function stat(cpu: number): Stat {
|
||||
return {
|
||||
cpu,
|
||||
memory: cpu,
|
||||
memoryUsage: cpu,
|
||||
networkRxTotal: 0,
|
||||
networkTxTotal: 0,
|
||||
diskReadTotal: 0,
|
||||
diskWriteTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContainer(id: string, cpu: number): Container {
|
||||
const stats = Array.from({ length: 10 }, () => stat(cpu));
|
||||
const now = new Date();
|
||||
return new Container(id, now, now, now, "img", id, "cmd", "host1", {}, "running", 0, 0, stats);
|
||||
}
|
||||
|
||||
describe("<MultiContainerStat />", () => {
|
||||
test("recalculates the charts when the container changes", async () => {
|
||||
// Mirror production: a parent holding a ref re-renders with a fresh
|
||||
// [container] array on switch. (VueTestUtils setProps cannot trigger this
|
||||
// because Container instances carry refs, which defeats prop change
|
||||
// detection on a direct prop assignment.)
|
||||
const current = ref<Container>(makeContainer("a", 10));
|
||||
const Parent = defineComponent({
|
||||
setup: () => () => h(MultiContainerStat as any, { containers: [current.value] }),
|
||||
});
|
||||
mount(Parent, {
|
||||
global: { plugins: [i18n], stubs: { BarChart: BarChartStub, IOCard: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
recalculate.mockClear();
|
||||
|
||||
// Switching containers replaces the whole stats series; the parent must
|
||||
// force the cached charts to fully recompute.
|
||||
current.value = makeContainer("b", 90);
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(recalculate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,87 @@
|
||||
<template>
|
||||
<div class="flex gap-1 md:gap-4">
|
||||
<div
|
||||
class="grid hidden min-w-15 grid-cols-[auto_1fr_auto_1fr] items-center gap-0.5 text-xs leading-none sm:grid md:grid-cols-[auto_1fr]"
|
||||
>
|
||||
<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
|
||||
ref="cpuMonitorRef"
|
||||
:data="cpuData"
|
||||
<div class="flex items-stretch gap-2.5">
|
||||
<IOCard
|
||||
:network-rx="networkRate.rx"
|
||||
:network-tx="networkRate.tx"
|
||||
:disk-read="diskRate.read"
|
||||
:disk-write="diskRate.write"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
: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) + '%'"
|
||||
/>
|
||||
<StatMonitor
|
||||
ref="memoryMonitorRef"
|
||||
:data="memoryData"
|
||||
card-class="bg-primary/10 md:min-w-56"
|
||||
icon-class="text-primary"
|
||||
:title="t('tooltip.cpu-usage', { cpu: totalStat.cpu.toFixed(2), cores: roundCPU(limits.cpu) })"
|
||||
>
|
||||
<template #value="{ hoveredValue }">
|
||||
<span class="tabular-nums">
|
||||
<span class="font-semibold"> {{ Math.max(0, hoveredValue ?? totalStat.cpu).toFixed(1) }}% </span>
|
||||
<span class="text-base-content/60 max-md:hidden"> / {{ roundCPU(limits.cpu) }} CPU</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #chart="{ onHoverValue }">
|
||||
<BarChart
|
||||
ref="cpuChart"
|
||||
:chart-data="cpuData"
|
||||
bar-class="bg-primary opacity-80 hover:opacity-100"
|
||||
class="h-5 w-full max-md:hidden"
|
||||
@hover-value="onHoverValue"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:icon="PhMemory"
|
||||
: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)"
|
||||
/>
|
||||
card-class="bg-secondary/10 md:min-w-56"
|
||||
icon-class="text-secondary"
|
||||
:title="
|
||||
t('tooltip.memory-usage', { used: formatBytes(totalStat.memoryUsage), total: formatBytes(limits.memory) })
|
||||
"
|
||||
>
|
||||
<template #value="{ hoveredValue }">
|
||||
<span class="tabular-nums">
|
||||
<span class="font-semibold">{{
|
||||
formatBytes(hoveredValue ?? totalStat.memoryUsage, { short: true, decimals: 1 })
|
||||
}}</span>
|
||||
<span class="text-base-content/60 max-md:hidden">
|
||||
/ {{ formatBytes(limits.memory, { short: true, decimals: 1 }) }}</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
<template #chart="{ onHoverValue }">
|
||||
<BarChart
|
||||
ref="memoryChart"
|
||||
:chart-data="memoryData"
|
||||
bar-class="bg-secondary opacity-80 hover:opacity-100"
|
||||
class="h-5 w-full max-md:hidden"
|
||||
@hover-value="onHoverValue"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Stat } from "@/models/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import StatMonitor from "@/components/LogViewer/StatMonitor.vue";
|
||||
// @ts-ignore
|
||||
import { Container, Stat, emptyStat } from "@/models/Container";
|
||||
import StatCard from "@/components/LogViewer/StatCard.vue";
|
||||
import IOCard from "@/components/LogViewer/IOCard.vue";
|
||||
import BarChart from "@/components/BarChart.vue";
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
|
||||
const cpuMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
|
||||
const memoryMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
|
||||
const { t } = useI18n();
|
||||
|
||||
const totalStat = ref<Stat>(emptyStat());
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
const cpuChart = useTemplateRef("cpuChart");
|
||||
const memoryChart = useTemplateRef("memoryChart");
|
||||
const networkRate = ref({ rx: 0, tx: 0 });
|
||||
const diskRate = ref({ read: 0, write: 0 });
|
||||
|
||||
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
|
||||
|
||||
@@ -68,37 +98,37 @@ watch(
|
||||
() => {
|
||||
const initial: Stat[] = [];
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = containers.reduce(
|
||||
(acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu / cores,
|
||||
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 },
|
||||
);
|
||||
const stat = containers.reduce((acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu / cores,
|
||||
memory: acc.memory + item.memory,
|
||||
memoryUsage: acc.memoryUsage + item.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + item.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + item.networkTxTotal,
|
||||
diskReadTotal: acc.diskReadTotal + item.diskReadTotal,
|
||||
diskWriteTotal: acc.diskWriteTotal + item.diskWriteTotal,
|
||||
};
|
||||
}, emptyStat());
|
||||
initial.push(stat);
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
// Charts cache their downsampled bars and only patch the last bar per tick;
|
||||
// a container switch replaces the whole series, so force a full recalculate.
|
||||
nextTick(() => {
|
||||
cpuMonitorRef.value?.recalculate();
|
||||
memoryMonitorRef.value?.recalculate();
|
||||
cpuChart.value?.recalculate();
|
||||
memoryChart.value?.recalculate();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const limits = computed(() => {
|
||||
// Group containers by host
|
||||
const containersByHost = new Map<string, Container[]>();
|
||||
containers.forEach((container) => {
|
||||
if (!containersByHost.has(container.host)) {
|
||||
@@ -110,77 +140,67 @@ const limits = computed(() => {
|
||||
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 + (c.cpuLimit || 0), 0);
|
||||
totalCpu += Math.min(sumCpu, hostTotalCpu);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
cpu: totalCpu,
|
||||
memory: totalMemory,
|
||||
};
|
||||
return { cpu: totalCpu, memory: totalMemory };
|
||||
});
|
||||
|
||||
useIntervalFn(() => {
|
||||
const previousStat = totalStat.value;
|
||||
totalStat.value = containers.reduce(
|
||||
(acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
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: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
|
||||
);
|
||||
totalStat.value = containers.reduce((acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
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,
|
||||
diskReadTotal: acc.diskReadTotal + container.stat.diskReadTotal,
|
||||
diskWriteTotal: acc.diskWriteTotal + container.stat.diskWriteTotal,
|
||||
};
|
||||
}, emptyStat());
|
||||
|
||||
networkRate.value = {
|
||||
rx: Math.max(0, totalStat.value.networkRxTotal - previousStat.networkRxTotal),
|
||||
tx: Math.max(0, totalStat.value.networkTxTotal - previousStat.networkTxTotal),
|
||||
};
|
||||
diskRate.value = {
|
||||
read: Math.max(0, totalStat.value.diskReadTotal - previousStat.diskReadTotal),
|
||||
write: Math.max(0, totalStat.value.diskWriteTotal - previousStat.diskWriteTotal),
|
||||
};
|
||||
}, 1000);
|
||||
|
||||
const cpuData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.cpu),
|
||||
history.value.map((stat) => ({
|
||||
percent: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu),
|
||||
})),
|
||||
);
|
||||
|
||||
const memoryData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.memory,
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.memory,
|
||||
value: stat.memoryUsage,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
<template>
|
||||
<table class="table-zebra table-pin-rows table-md table" v-if="!loading">
|
||||
<div class="w-full overflow-x-auto" v-if="!loading">
|
||||
<table class="table-zebra table-pin-rows table-md table" v-if="columns.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column" class="font-mono">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in table" :key="index">
|
||||
<td v-for="column in columns" :key="column" class="max-w-md align-top">
|
||||
<span v-if="format(row[column]) === null" class="text-base-content/30 italic">NULL</span>
|
||||
<span v-else class="block truncate font-mono" :title="format(row[column]) ?? undefined">{{
|
||||
format(row[column])
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="text-base-content/50 flex flex-col items-center gap-2 py-16">
|
||||
<ph:database class="size-8 opacity-40" />
|
||||
<span>{{ $t("analytics.no_results") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table-md table" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in table" :key="row">
|
||||
<td v-for="column in columns" :key="column">{{ row[column] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table-md table animate-pulse" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="_ in 3">
|
||||
<th v-for="i in 3" :key="i">
|
||||
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-50"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="_ in 9">
|
||||
<td v-for="_ in 3">
|
||||
<div class="bg-base-content/50 h-4 w-20 opacity-20"></div>
|
||||
<tr v-for="i in 9" :key="i">
|
||||
<td v-for="j in 3" :key="j">
|
||||
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-20"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -37,4 +48,17 @@ const { loading, table } = defineProps<{
|
||||
}>();
|
||||
|
||||
const columns = computed(() => (table.numRows > 0 ? Object.keys(table.get(0) as Record<string, any>) : []));
|
||||
|
||||
function format(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import SearchStatus from "./SearchStatus.vue";
|
||||
import IndeterminateBar from "@/components/common/IndeterminateBar.vue";
|
||||
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
label: {
|
||||
"search-status": {
|
||||
searching: "Searching older logs…",
|
||||
"searching-to": "Searching older logs… back to {time}",
|
||||
capped: "{count} matches · searched back to {time}",
|
||||
exhausted: "Searched all logs · {count} matches",
|
||||
empty: "No matches · searched all logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function createStatus(overrides: Record<string, unknown> = {}) {
|
||||
return mount(SearchStatus, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
status: { active: false, done: false, matches: 0, scannedTo: undefined, reason: undefined, ...overrides },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("<SearchStatus />", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("stays hidden while a search is active but still fast (flash avoidance)", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(wrapper.find("[data-state]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("shows searching state once a search runs past the reveal delay", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent(IndeterminateBar).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("reveals the searching bar even when progress events arrive faster than the delay", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
// a slow search emits a progress event each window; the reveal delay must
|
||||
// measure from when the search started, not restart on every event
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(100);
|
||||
await wrapper.setProps({ status: { active: true, done: false, matches: i, scannedTo: `t${i}` } });
|
||||
await nextTick();
|
||||
}
|
||||
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("shows the empty state when a search finishes with no matches", async () => {
|
||||
const wrapper = createStatus({ active: false, done: true, matches: 0, reason: "exhausted" });
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="empty"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("shows a completion summary for a slow exhausted search", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
await wrapper.setProps({ status: { active: false, done: true, matches: 3, reason: "exhausted" } });
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="exhausted"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
|
||||
test("shows a capped summary for a slow capped search", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
await wrapper.setProps({
|
||||
status: { active: false, done: true, matches: 50, reason: "capped", scannedTo: "2026-06-01T13:10:00Z" },
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="capped"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("stays quiet for a fast search that returned matches", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
await wrapper.setProps({ status: { active: false, done: true, matches: 5, reason: "capped" } });
|
||||
await nextTick();
|
||||
expect(wrapper.find("[data-state]").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="state"
|
||||
:data-state="state"
|
||||
class="bg-base-200/80 text-base-content/70 flex items-center gap-2 px-4 py-1.5 text-xs backdrop-blur"
|
||||
>
|
||||
<template v-if="state === 'searching'">
|
||||
<span>{{
|
||||
status.scannedTo ? $t("label.search-status.searching-to", { time }) : $t("label.search-status.searching")
|
||||
}}</span>
|
||||
<IndeterminateBar color="primary" class="ml-auto" />
|
||||
</template>
|
||||
<span v-else-if="state === 'empty'">{{ $t("label.search-status.empty") }}</span>
|
||||
<span v-else-if="state === 'capped'" class="tabular-nums">
|
||||
{{ $t("label.search-status.capped", { count: status.matches, time }) }}
|
||||
</span>
|
||||
<span v-else-if="state === 'exhausted'" class="tabular-nums">
|
||||
{{ $t("label.search-status.exhausted", { count: status.matches }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type SearchStatus } from "@/composable/eventStreams";
|
||||
|
||||
const props = defineProps<{ status: SearchStatus }>();
|
||||
|
||||
// Reveal the in-progress bar only after a short delay so fast searches (the
|
||||
// common case, which return almost instantly) never flash it. Slow searches
|
||||
// — sparse matches over a large log — are the only ones that surface it.
|
||||
const showSearching = ref(false);
|
||||
// Remember whether this search ever ran slow, so the completion summary only
|
||||
// shows for searches that actually made the user wait.
|
||||
const wasSlow = ref(false);
|
||||
|
||||
// Watch the boolean, not the whole status object: a slow search replaces the
|
||||
// status object on every progress event, and re-arming the timer each time
|
||||
// would keep the bar from ever appearing. The delay must measure from when the
|
||||
// search started.
|
||||
const active = computed(() => props.status.active);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(
|
||||
active,
|
||||
(isActive) => {
|
||||
clearTimeout(timer);
|
||||
if (isActive) {
|
||||
timer = setTimeout(() => {
|
||||
showSearching.value = true;
|
||||
wasSlow.value = true;
|
||||
}, 400);
|
||||
} else {
|
||||
showSearching.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onScopeDispose(() => clearTimeout(timer));
|
||||
|
||||
const time = computed(() => (props.status.scannedTo ? new Date(props.status.scannedTo).toLocaleString() : ""));
|
||||
|
||||
const state = computed<"searching" | "empty" | "capped" | "exhausted" | null>(() => {
|
||||
if (showSearching.value) return "searching";
|
||||
if (!props.status.active && props.status.done) {
|
||||
if (props.status.matches === 0) return "empty";
|
||||
if (wasSlow.value) return props.status.reason === "capped" ? "capped" : "exhausted";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
@@ -9,17 +9,8 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
}>();
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-px rounded-md px-2.5 py-1"
|
||||
:class="cardClass"
|
||||
:title="title"
|
||||
@mouseleave="hoveredValue = null"
|
||||
>
|
||||
<div class="flex h-[18px] items-center gap-2 text-[12.5px] font-medium">
|
||||
<component :is="icon" class="size-3.5 shrink-0" :class="iconClass" />
|
||||
<slot name="value" :hoveredValue="hoveredValue" />
|
||||
</div>
|
||||
<slot name="chart" :onHoverValue="(v: number) => (hoveredValue = v)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
cardClass?: string;
|
||||
iconClass?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const hoveredValue = ref<number | null>(null);
|
||||
</script>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="mouseOver = true"
|
||||
@mouseleave="
|
||||
mouseOver = false;
|
||||
hoveredValue = null;
|
||||
"
|
||||
:class="textClass"
|
||||
>
|
||||
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
|
||||
<BarChart
|
||||
ref="barChartRef"
|
||||
:chart-data="chartData"
|
||||
:bar-class="`${barClass} opacity-70 hover:opacity-100`"
|
||||
class="h-8 w-44"
|
||||
@hover-value="(value: number) => (hoveredValue = value)"
|
||||
/>
|
||||
</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 }}
|
||||
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
import BarChart from "@/components/BarChart.vue";
|
||||
|
||||
const {
|
||||
data,
|
||||
icon,
|
||||
statValue,
|
||||
limit = -1,
|
||||
containerClass = "border-primary",
|
||||
textClass = "",
|
||||
barClass = "bg-primary",
|
||||
formatter,
|
||||
} = defineProps<{
|
||||
data: Point<number>[];
|
||||
icon: Component;
|
||||
statValue: string | number;
|
||||
limit?: string | number;
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatter?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const chartData = computed(() =>
|
||||
data.map((point) => ({
|
||||
percent: point.y ?? 0,
|
||||
value: point.value ?? point.y ?? 0,
|
||||
})),
|
||||
);
|
||||
const barChartRef = ref<InstanceType<typeof BarChart> | null>(null);
|
||||
const mouseOver = ref(false);
|
||||
const hoveredValue = ref<number | null>(null);
|
||||
|
||||
defineExpose({ recalculate: () => barChartRef.value?.recalculate() });
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (mouseOver.value && hoveredValue.value !== null) {
|
||||
if (formatter) {
|
||||
return formatter(hoveredValue.value);
|
||||
}
|
||||
return hoveredValue.value.toFixed(2);
|
||||
}
|
||||
return statValue;
|
||||
});
|
||||
</script>
|
||||
@@ -21,7 +21,7 @@ defineExpose({
|
||||
clear: () => source.value?.clear(),
|
||||
});
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
onKeyStroke(["l", "L"], (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
source.value?.clear();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -7,17 +7,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
@@ -48,17 +48,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
@@ -89,17 +89,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg></a>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
|
||||
</svg> action.see-in-context</a></li>
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-lg border p-2 md:p-3" :class="containerClass">
|
||||
<div class="rounded-lg 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>
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<mdi:chart-line v-if="alert.metricExpression" class="text-info" />
|
||||
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info" />
|
||||
<mdi:text-box-outline v-else class="text-info" />
|
||||
<span>{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h4 class="flex min-w-0 flex-wrap items-center gap-2 text-lg font-semibold">
|
||||
<mdi:chart-line v-if="alert.metricExpression" class="text-info shrink-0" />
|
||||
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info shrink-0" />
|
||||
<mdi:text-box-outline v-else class="text-info shrink-0" />
|
||||
<span class="break-all">{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<div class="group/dispatch dropdown dropdown-hover">
|
||||
<div
|
||||
tabindex="0"
|
||||
@@ -48,7 +48,12 @@
|
||||
</h4>
|
||||
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
|
||||
</div>
|
||||
<input type="checkbox" class="toggle toggle-primary" :checked="alert.enabled" @change="toggleEnabled" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary shrink-0"
|
||||
:checked="alert.enabled"
|
||||
@change="toggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Expressions -->
|
||||
@@ -78,8 +83,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-content/10 text-base-content/80 flex items-center justify-between border-t pt-3 text-xs">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="border-base-content/10 text-base-content/80 flex items-center justify-between gap-2 border-t pt-3 text-xs"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span>
|
||||
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
|
||||
</span>
|
||||
@@ -90,7 +97,7 @@
|
||||
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button class="btn btn-ghost btn-square" @click="editAlert">
|
||||
<mdi:pencil-outline />
|
||||
</button>
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
<li>
|
||||
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
|
||||
</li>
|
||||
<li v-if="destination.type !== 'cloud'">
|
||||
<a @click="duplicateDestination">{{ $t("notifications.destination.duplicate") }}</a>
|
||||
</li>
|
||||
<li v-if="destination.type !== 'cloud'">
|
||||
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
|
||||
</li>
|
||||
@@ -61,6 +64,21 @@ function editDestination() {
|
||||
);
|
||||
}
|
||||
|
||||
async function duplicateDestination() {
|
||||
await fetch(withBase("/api/notifications/dispatchers"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: `Copy of ${destination.name}`,
|
||||
type: destination.type,
|
||||
url: destination.url,
|
||||
template: destination.template,
|
||||
headers: destination.headers,
|
||||
}),
|
||||
});
|
||||
onUpdated?.();
|
||||
}
|
||||
|
||||
async function deleteDestination() {
|
||||
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
|
||||
onUpdated?.();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.event-fields-hint", {
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, exitCode, signal, etc.)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
data-testid="scrollable-header"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[var(--mobile-nav-height)] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import Search from "./Search.vue";
|
||||
import { useSearchFilter } from "@/composable/search";
|
||||
|
||||
const search = useSearchFilter();
|
||||
|
||||
function mountSearch() {
|
||||
search.showSearch.value = true;
|
||||
return mount(Search, {
|
||||
global: { plugins: [createI18n({})] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("<Search />", () => {
|
||||
beforeEach(() => {
|
||||
search.resetSearch();
|
||||
});
|
||||
|
||||
test("flags an invalid regex with a warning style", async () => {
|
||||
const wrapper = mountSearch();
|
||||
search.searchQueryFilter.value = "valid";
|
||||
await nextTick();
|
||||
expect(wrapper.find(".input").classes()).not.toContain("input-warning");
|
||||
|
||||
search.searchQueryFilter.value = "[";
|
||||
await nextTick();
|
||||
expect(wrapper.find(".input").classes()).toContain("input-warning");
|
||||
});
|
||||
|
||||
test("binds the input to the shared search filter", async () => {
|
||||
const wrapper = mountSearch();
|
||||
await wrapper.find("input").setValue("abc");
|
||||
expect(search.searchQueryFilter.value).toBe("abc");
|
||||
});
|
||||
|
||||
test("toggles the inverse filter", async () => {
|
||||
const wrapper = mountSearch();
|
||||
expect(search.inverseFilter.value).toBe(false);
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(search.inverseFilter.value).toBe(true);
|
||||
});
|
||||
|
||||
test("escape resets the search", async () => {
|
||||
const wrapper = mountSearch();
|
||||
search.searchQueryFilter.value = "abc";
|
||||
await wrapper.find("input").trigger("keyup.esc");
|
||||
expect(search.searchQueryFilter.value).toBe("");
|
||||
expect(search.showSearch.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div
|
||||
class="fixed z-50 flex w-full justify-end p-2"
|
||||
v-show="showSearch"
|
||||
v-if="search"
|
||||
ref="container"
|
||||
:style="style"
|
||||
>
|
||||
<div class="fixed z-50 flex w-full justify-end p-2" v-show="showSearch" ref="container" :style="style">
|
||||
<div class="input input-primary flex items-center shadow-lg" :class="!isValidQuery ? 'input-warning' : ''">
|
||||
<mdi:magnify />
|
||||
<input
|
||||
@@ -17,6 +11,15 @@
|
||||
v-model="searchQueryFilter"
|
||||
@keyup.esc="resetSearch()"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-circle btn-xs"
|
||||
:class="inverseFilter ? 'btn-error' : 'btn-ghost'"
|
||||
@click="toggleInverse()"
|
||||
:title="inverseFilter ? $t('toolbar.inverse-on') : $t('toolbar.inverse-off')"
|
||||
>
|
||||
<mdi:filter-off-outline v-if="inverseFilter" />
|
||||
<mdi:filter-outline v-else />
|
||||
</button>
|
||||
<a class="btn btn-circle btn-xs" @click="resetSearch()"> <mdi:close /></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,11 +29,12 @@
|
||||
<script lang="ts" setup>
|
||||
const input = ref<HTMLInputElement>();
|
||||
const container = ref<HTMLDivElement>();
|
||||
const { searchQueryFilter, showSearch, resetSearch, isValidQuery } = useSearchFilter();
|
||||
const { searchQueryFilter, showSearch, resetSearch, isValidQuery, inverseFilter, toggleInverse } = useSearchFilter();
|
||||
|
||||
const { style } = useDraggable(container);
|
||||
|
||||
onKeyStroke("f", (e) => {
|
||||
if (!search.value) return;
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
showSearch.value = true;
|
||||
nextTick(() => input.value?.focus() || input.value?.select());
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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">
|
||||
<Logo class="h-14 w-14 shrink-0" />
|
||||
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-2.5 overflow-hidden text-4xl font-thin">
|
||||
<Logo class="h-11 w-11 shrink-0" />
|
||||
Dozzle
|
||||
</router-link>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -21,7 +21,27 @@ const i18n = createI18n({
|
||||
fallbackLocale: "en",
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en: { cloud: { welcome: { "default-alert-name": "Container exited with error" } } } },
|
||||
messages: {
|
||||
en: {
|
||||
cloud: {
|
||||
welcome: {
|
||||
"create-alerts": "Turn on selected signals",
|
||||
signals: {
|
||||
exited: "Container exited with an error",
|
||||
"exited-desc": "Fires when a container stops with a non-zero exit code.",
|
||||
unhealthy: "Container became unhealthy",
|
||||
"unhealthy-desc": "Fires when a container's healthcheck transitions to unhealthy.",
|
||||
oom: "Container was killed by the kernel (OOM)",
|
||||
"oom-desc": "Fires when Docker reports an out-of-memory kill.",
|
||||
restart: "Container restarted",
|
||||
"restart-desc": "Off by default — noisy on its own; Cloud also uses this for loop detection.",
|
||||
disk: "Disk space running low on any volume",
|
||||
"disk-desc": "Fires when any mounted volume is over 85% full.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function mountModal() {
|
||||
@@ -50,7 +70,15 @@ describe("<WelcomeModal /> Create First Alert", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("POSTs default rule with cloud dispatcher id and routes to /notifications?highlight=<id>", async () => {
|
||||
async function openAndAdvance(wrapper: ReturnType<typeof mountModal>) {
|
||||
// open() seeds defaultOn signals
|
||||
(wrapper.vm as unknown as { open: () => void }).open();
|
||||
const vm = wrapper.vm as unknown as { step: "step1" | "step2" };
|
||||
vm.step = "step2";
|
||||
await wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
test("POSTs one rule per checked default signal and routes to /notifications", async () => {
|
||||
const fetchMock = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/notifications/dispatchers")) {
|
||||
@@ -64,31 +92,50 @@ describe("<WelcomeModal /> Create First Alert", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const wrapper = mountModal();
|
||||
const vm = wrapper.vm as unknown as { step: "step1" | "step2" } & Record<string, unknown>;
|
||||
// jump to step2 to expose the CTA
|
||||
vm.step = "step2";
|
||||
await wrapper.vm.$nextTick();
|
||||
await openAndAdvance(wrapper);
|
||||
|
||||
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("create"));
|
||||
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
|
||||
expect(cta).toBeDefined();
|
||||
await cta!.trigger("click");
|
||||
// allow async fetch chain to settle
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const postCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/api/notifications/rules"));
|
||||
expect(postCall).toBeDefined();
|
||||
const body = JSON.parse((postCall![1] as RequestInit).body as string);
|
||||
expect(body).toMatchObject({
|
||||
const ruleCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes("/api/notifications/rules"));
|
||||
expect(ruleCalls).toHaveLength(4); // exited + unhealthy + oom + disk on by default; restart off
|
||||
|
||||
const bodies = ruleCalls.map((c) => JSON.parse((c[1] as RequestInit).body as string));
|
||||
const eventExpressions = bodies.map((b) => b.eventExpression).filter(Boolean);
|
||||
expect(eventExpressions).toContain('name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])');
|
||||
expect(eventExpressions).toContain('name == "health_status" && attributes["healthStatus"] == "unhealthy"');
|
||||
expect(eventExpressions).toContain('name == "oom"');
|
||||
expect(eventExpressions).not.toContain('name == "restart"');
|
||||
|
||||
const metricExpressions = bodies.map((b) => b.metricExpression).filter(Boolean);
|
||||
expect(metricExpressions).toContain("any(mounts, .usedPercent >= 85)");
|
||||
|
||||
// disk rule should carry its own cooldown/sampleWindow; event rules should remain at 0
|
||||
const diskBody = bodies.find((b) => b.metricExpression === "any(mounts, .usedPercent >= 85)");
|
||||
expect(diskBody).toMatchObject({
|
||||
enabled: true,
|
||||
dispatcherId: 7,
|
||||
eventExpression: 'name == "die" && attributes["exitCode"] != "0"',
|
||||
cooldown: 0,
|
||||
sampleWindow: 0,
|
||||
cooldown: 3600,
|
||||
sampleWindow: 60,
|
||||
containerExpression: "true",
|
||||
eventExpression: "",
|
||||
});
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { highlight: "42" } });
|
||||
// event-based POSTs use cloud dispatcher id with no cooldown
|
||||
for (const b of bodies.filter((x) => x.eventExpression)) {
|
||||
expect(b).toMatchObject({
|
||||
enabled: true,
|
||||
dispatcherId: 7,
|
||||
cooldown: 0,
|
||||
sampleWindow: 0,
|
||||
containerExpression: "true",
|
||||
metricExpression: "",
|
||||
});
|
||||
}
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications" });
|
||||
});
|
||||
|
||||
test("falls back to ?action=create-alert when POST fails", async () => {
|
||||
@@ -105,15 +152,11 @@ describe("<WelcomeModal /> Create First Alert", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const wrapper = mountModal();
|
||||
const vm = wrapper.vm as unknown as { step: "step1" | "step2" } & Record<string, unknown>;
|
||||
vm.step = "step2";
|
||||
await wrapper.vm.$nextTick();
|
||||
await openAndAdvance(wrapper);
|
||||
|
||||
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("create"));
|
||||
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
|
||||
await cta!.trigger("click");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { action: "create-alert" } });
|
||||
});
|
||||
|
||||
@@ -42,39 +42,41 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Onboarding Checklist -->
|
||||
<!-- Step 2: Triage signal checklist -->
|
||||
<template v-else-if="step === 'step2'">
|
||||
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.step2-title") }}</h3>
|
||||
<p class="text-base-content/60 mt-1 text-sm">{{ $t("cloud.welcome.step2-subtitle") }}</p>
|
||||
<p class="text-base-content/60 mt-2 text-sm">{{ $t("cloud.welcome.step2-body") }}</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div v-for="(item, index) in checklistItems" :key="index" class="flex gap-3">
|
||||
<div
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
:class="index === 0 ? 'bg-primary text-primary-content' : 'bg-base-200 text-base-content/60'"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
<div class="mt-5 space-y-3">
|
||||
<label
|
||||
v-for="signal in signals"
|
||||
:key="signal.key"
|
||||
class="border-base-300 hover:border-primary/40 flex cursor-pointer gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<input
|
||||
v-model="selectedSignals"
|
||||
type="checkbox"
|
||||
:value="signal.key"
|
||||
class="checkbox checkbox-primary checkbox-sm mt-0.5"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold">{{ signal.label }}</p>
|
||||
<p class="text-base-content/60 text-xs">{{ signal.description }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<component
|
||||
:is="item.href ? 'a' : 'p'"
|
||||
class="text-sm font-semibold"
|
||||
:class="item.href ? 'link link-hover' : ''"
|
||||
:href="item.href"
|
||||
:target="item.href ? '_blank' : undefined"
|
||||
:rel="item.href ? 'noreferrer noopener' : undefined"
|
||||
>
|
||||
{{ item.title }}
|
||||
</component>
|
||||
<p class="text-base-content/60 text-xs">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block mt-6" @click="createFirstAlert">
|
||||
{{ $t("cloud.welcome.create-alert") }}
|
||||
<p class="text-base-content/60 mt-4 text-xs">{{ $t("cloud.welcome.footer") }}</p>
|
||||
|
||||
<button
|
||||
class="btn btn-primary btn-block mt-5"
|
||||
:disabled="creating || selectedSignals.length === 0"
|
||||
@click="createDefaultAlerts"
|
||||
>
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs"></span>
|
||||
{{ $t("cloud.welcome.create-alerts") }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-block btn-sm mt-1" @click="close">
|
||||
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="creating" @click="close">
|
||||
{{ $t("cloud.welcome.later") }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -86,7 +88,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -97,34 +98,86 @@ const step = ref<"step1" | "step2">("step1");
|
||||
const intent = ref("");
|
||||
const selectedOptions = ref(new Set<string>());
|
||||
const submitting = ref(false);
|
||||
const creating = ref(false);
|
||||
let feedbackSent = false;
|
||||
|
||||
const chipOptions = [
|
||||
{ value: "error_alerts", label: t("cloud.welcome.chip-alerts") },
|
||||
{ value: "ai_assistant", label: t("cloud.welcome.chip-assistant") },
|
||||
{ value: "multiple_hosts", label: t("cloud.welcome.chip-hosts") },
|
||||
{ value: "search_logs", label: t("cloud.welcome.chip-search-logs") },
|
||||
{ value: "remote_access", label: t("cloud.welcome.chip-remote-access") },
|
||||
{ value: "log_digests", label: t("cloud.welcome.chip-digests") },
|
||||
{ value: "something_else", label: t("cloud.welcome.chip-other") },
|
||||
];
|
||||
|
||||
const checklistItems = computed(() => [
|
||||
type SignalKey = "exited" | "unhealthy" | "oom" | "restart" | "disk";
|
||||
type SignalKind = "event" | "metric";
|
||||
|
||||
interface SignalDef {
|
||||
key: SignalKey;
|
||||
kind: SignalKind;
|
||||
label: string;
|
||||
description: string;
|
||||
// ruleName is intentionally English/stable so the rule stays recognizable
|
||||
// if the user later switches locale.
|
||||
ruleName: string;
|
||||
expression: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
const signals = computed<SignalDef[]>(() => [
|
||||
{
|
||||
title: t("cloud.welcome.checklist-alert-title"),
|
||||
description: t("cloud.welcome.checklist-alert-desc"),
|
||||
key: "exited",
|
||||
kind: "event",
|
||||
label: t("cloud.welcome.signals.exited"),
|
||||
description: t("cloud.welcome.signals.exited-desc"),
|
||||
ruleName: "Container exited with an error",
|
||||
// Ignore clean/graceful shutdowns: 0 (success), 130 (SIGINT), 143 (SIGTERM), 137 (SIGKILL).
|
||||
// These commonly fire on `docker stop`, Ctrl+C, and Watchtower update cycles, which are
|
||||
// not errors. Still alerts on genuine error exits (1, 2, 125, ...) and crash-loops.
|
||||
expression: 'name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])',
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
title: t("cloud.welcome.checklist-notify-title"),
|
||||
description: t("cloud.welcome.checklist-notify-desc"),
|
||||
href: `${cloudUrl}/channels`,
|
||||
key: "unhealthy",
|
||||
kind: "event",
|
||||
label: t("cloud.welcome.signals.unhealthy"),
|
||||
description: t("cloud.welcome.signals.unhealthy-desc"),
|
||||
ruleName: "Container became unhealthy",
|
||||
expression: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
title: t("cloud.welcome.checklist-agent-title"),
|
||||
description: t("cloud.welcome.checklist-agent-desc"),
|
||||
href: `${cloudUrl}/assistant`,
|
||||
key: "oom",
|
||||
kind: "event",
|
||||
label: t("cloud.welcome.signals.oom"),
|
||||
description: t("cloud.welcome.signals.oom-desc"),
|
||||
ruleName: "Container killed (OOM)",
|
||||
expression: 'name == "oom"',
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
key: "restart",
|
||||
kind: "event",
|
||||
label: t("cloud.welcome.signals.restart"),
|
||||
description: t("cloud.welcome.signals.restart-desc"),
|
||||
ruleName: "Container restarted",
|
||||
expression: 'name == "restart"',
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
key: "disk",
|
||||
kind: "metric",
|
||||
label: t("cloud.welcome.signals.disk"),
|
||||
description: t("cloud.welcome.signals.disk-desc"),
|
||||
ruleName: "Volume running out of space",
|
||||
expression: "any(mounts, .usedPercent >= 85)",
|
||||
defaultOn: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedSignals = ref<SignalKey[]>([]);
|
||||
|
||||
function toggleOption(value: string) {
|
||||
const next = new Set(selectedOptions.value);
|
||||
if (next.has(value)) {
|
||||
@@ -160,7 +213,7 @@ async function submitFeedback() {
|
||||
await postFeedback(false);
|
||||
submitting.value = false;
|
||||
if (onNotificationsPage.value) {
|
||||
createFirstAlert();
|
||||
await createDefaultAlerts();
|
||||
} else {
|
||||
step.value = "step2";
|
||||
}
|
||||
@@ -172,41 +225,64 @@ async function skipFeedback() {
|
||||
await postFeedback(true);
|
||||
submitting.value = false;
|
||||
if (onNotificationsPage.value) {
|
||||
createFirstAlert();
|
||||
// User explicitly skipped — don't silently create defaults on their behalf.
|
||||
// They're already on the notifications page; just dismiss.
|
||||
close();
|
||||
} else {
|
||||
step.value = "step2";
|
||||
}
|
||||
}
|
||||
|
||||
async function createFirstAlert() {
|
||||
close();
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
async function createDefaultAlerts() {
|
||||
if (creating.value) return;
|
||||
creating.value = true;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
const chosen = signals.value.filter((s) => selectedSignals.value.includes(s.key));
|
||||
try {
|
||||
const dispatchersRes = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
const dispatchersRes = await fetch(withBase("/api/notifications/dispatchers"), { signal });
|
||||
if (!dispatchersRes.ok) throw new Error("dispatchers fetch failed");
|
||||
const dispatchers: Array<{ id: number; type: string }> = await dispatchersRes.json();
|
||||
const cloud = dispatchers.find((d) => d.type === "cloud");
|
||||
if (!cloud) throw new Error("cloud dispatcher missing");
|
||||
|
||||
const ruleRes = await fetch(withBase("/api/notifications/rules"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: t("cloud.welcome.default-alert-name"),
|
||||
enabled: true,
|
||||
dispatcherId: cloud.id,
|
||||
logExpression: "",
|
||||
containerExpression: "true",
|
||||
eventExpression: 'name == "die" && attributes["exitCode"] != "0"',
|
||||
metricExpression: "",
|
||||
cooldown: 0,
|
||||
sampleWindow: 0,
|
||||
}),
|
||||
});
|
||||
if (!ruleRes.ok) throw new Error("rule POST failed");
|
||||
const rule: { id: number } = await ruleRes.json();
|
||||
// Fire rule POSTs in parallel. Partial failure is not cleaned up — if one
|
||||
// rejects, the earlier ones are already saved and the user lands on the
|
||||
// fallback toast path. Acceptable for a welcome modal; the user can edit
|
||||
// or delete rules from /notifications.
|
||||
await Promise.all(
|
||||
chosen.map((s) =>
|
||||
fetch(withBase("/api/notifications/rules"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
name: s.ruleName,
|
||||
enabled: true,
|
||||
dispatcherId: cloud.id,
|
||||
logExpression: "",
|
||||
containerExpression: "true",
|
||||
eventExpression: s.kind === "event" ? s.expression : "",
|
||||
metricExpression: s.kind === "metric" ? s.expression : "",
|
||||
// Metric alerts: don't re-fire more than once an hour per container,
|
||||
// and require the threshold to hold for the default sample window.
|
||||
cooldown: s.kind === "metric" ? 3600 : 0,
|
||||
sampleWindow: s.kind === "metric" ? 60 : 0,
|
||||
}),
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error("rule POST failed");
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
router.push({ path: "/notifications", query: { highlight: String(rule.id) } });
|
||||
} catch {
|
||||
close();
|
||||
router.push({ path: "/notifications" });
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") return;
|
||||
close();
|
||||
showToast(
|
||||
{
|
||||
type: "warning",
|
||||
@@ -215,6 +291,8 @@ async function createFirstAlert() {
|
||||
{ expire: 6000 },
|
||||
);
|
||||
router.push({ path: "/notifications", query: { action: "create-alert" } });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +300,7 @@ function open() {
|
||||
step.value = "step1";
|
||||
intent.value = "";
|
||||
selectedOptions.value = new Set();
|
||||
selectedSignals.value = signals.value.filter((s) => s.defaultOn).map((s) => s.key);
|
||||
feedbackSent = false;
|
||||
modal.value?.showModal();
|
||||
}
|
||||
@@ -230,6 +309,10 @@ function close() {
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
function onClose() {
|
||||
if (step.value === "step1" && !feedbackSent) {
|
||||
feedbackSent = true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import JsonText from "./JsonText.vue";
|
||||
|
||||
function mountText(text: string, highlight?: string) {
|
||||
return mount(JsonText, { props: { text, highlight } });
|
||||
}
|
||||
|
||||
describe("<JsonText />", () => {
|
||||
test("renders plain text without a highlight", () => {
|
||||
const wrapper = mountText("hello");
|
||||
expect(wrapper.text()).toBe("hello");
|
||||
expect(wrapper.find("mark").exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("marks the matching substring", () => {
|
||||
const wrapper = mountText("hello", "ell");
|
||||
const marks = wrapper.findAll("mark");
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0].text()).toBe("ell");
|
||||
expect(wrapper.text()).toBe("hello");
|
||||
});
|
||||
|
||||
test("matches case-insensitively", () => {
|
||||
const wrapper = mountText("Hello", "ELL");
|
||||
expect(wrapper.find("mark").text()).toBe("ell");
|
||||
});
|
||||
|
||||
test("treats regex metacharacters literally", () => {
|
||||
// Without escaping, "." would match every character; escaped it matches only dots.
|
||||
const wrapper = mountText("a.b.c", ".");
|
||||
const marks = wrapper.findAll("mark");
|
||||
expect(marks).toHaveLength(2);
|
||||
expect(marks.every((m) => m.text() === ".")).toBe(true);
|
||||
});
|
||||
|
||||
test("marks every occurrence", () => {
|
||||
const wrapper = mountText("xax", "x");
|
||||
expect(wrapper.findAll("mark")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import JsonValue from "./JsonValue.vue";
|
||||
|
||||
// indent < 0 forces the compact inline rendering, which is deterministic to assert.
|
||||
function inline(value: unknown, highlight?: string) {
|
||||
return mount(JsonValue, { props: { value, indent: -1, highlight } });
|
||||
}
|
||||
|
||||
// wrapper.text() drops inter-node whitespace, so compare structure without spaces.
|
||||
function compact(wrapper: ReturnType<typeof inline>) {
|
||||
return wrapper.text().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
describe("<JsonValue /> primitives", () => {
|
||||
test("null", () => {
|
||||
const wrapper = inline(null);
|
||||
expect(wrapper.text()).toBe("null");
|
||||
expect(wrapper.find(".json-null").exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("boolean", () => {
|
||||
expect(inline(true).text()).toBe("true");
|
||||
expect(inline(false).find(".json-boolean").exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("number", () => {
|
||||
const wrapper = inline(42);
|
||||
expect(wrapper.text()).toBe("42");
|
||||
expect(wrapper.find(".json-number").exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("string is quoted", () => {
|
||||
const wrapper = inline("hi");
|
||||
expect(wrapper.text()).toBe('"hi"');
|
||||
expect(wrapper.find(".json-string").exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<JsonValue /> structures", () => {
|
||||
test("empty array and object", () => {
|
||||
expect(inline([]).text()).toBe("[]");
|
||||
expect(inline({}).text()).toBe("{}");
|
||||
});
|
||||
|
||||
test("flat array", () => {
|
||||
expect(compact(inline([1, 2]))).toBe("[1,2]");
|
||||
});
|
||||
|
||||
test("flat object", () => {
|
||||
expect(compact(inline({ a: 1 }))).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
test("nested object", () => {
|
||||
expect(compact(inline({ a: { b: 1 } }))).toBe('{"a":{"b":1}}');
|
||||
});
|
||||
|
||||
test("indent mode emits newline spans, inline mode does not", () => {
|
||||
const indented = mount(JsonValue, { props: { value: { a: 1 }, indent: 0 } });
|
||||
expect(indented.findAll(".json-newline").length).toBeGreaterThan(0);
|
||||
expect(inline({ a: 1 }).findAll(".json-newline")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<JsonValue /> highlight", () => {
|
||||
test("passes highlight down to string values", () => {
|
||||
const wrapper = inline("hello", "ell");
|
||||
expect(wrapper.find("mark").text()).toBe("ell");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
|
||||
<div class="p-2">
|
||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: '/' }">
|
||||
<Logo class="h-10" />
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
<template>
|
||||
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
||||
<div class="modal-box" :width="width">
|
||||
<div class="modal-box" :width="maximized ? 'full' : width">
|
||||
<div class="pt-safe relative">
|
||||
<form method="dialog" class="absolute right-0">
|
||||
<button v-if="isMobile">
|
||||
<mdi:close />
|
||||
<div class="absolute right-0 flex items-center gap-3">
|
||||
<button
|
||||
v-if="!isMobile"
|
||||
class="hover:text-base-content/60 cursor-pointer outline-hidden"
|
||||
type="button"
|
||||
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||
@click="maximized = !maximized"
|
||||
>
|
||||
<mdi:arrow-collapse v-if="maximized" />
|
||||
<mdi:arrow-expand v-else />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
<form method="dialog">
|
||||
<button v-if="isMobile" class="cursor-pointer">
|
||||
<mdi:close />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active cursor-pointer outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<slot v-if="open" :close="close"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,6 +37,7 @@ import { type DrawerWidth } from "@/composable/drawer";
|
||||
const panel = useTemplateRef<HTMLDialogElement>("panel");
|
||||
|
||||
const open = ref(false);
|
||||
const maximized = ref(false);
|
||||
const { width } = defineProps<{
|
||||
width: DrawerWidth;
|
||||
}>();
|
||||
@@ -35,6 +49,7 @@ function close() {
|
||||
defineExpose({
|
||||
open: () => {
|
||||
open.value = true;
|
||||
maximized.value = false;
|
||||
panel.value?.showModal();
|
||||
},
|
||||
close,
|
||||
@@ -55,6 +70,10 @@ useEventListener(panel, "close", () => (open.value = false));
|
||||
&[width="lg"] {
|
||||
@apply max-w-5xl;
|
||||
}
|
||||
|
||||
&[width="full"] {
|
||||
@apply w-full max-w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-right[open] .modal-box {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Component } from "vue";
|
||||
import { Container } from "@/models/Container";
|
||||
import { useContainerActions } from "@/composable/containerActions";
|
||||
import config from "@/stores/config";
|
||||
import {
|
||||
lightTheme,
|
||||
compact,
|
||||
showTimestamp,
|
||||
softWrap,
|
||||
showAllContainers,
|
||||
showStd,
|
||||
smallerScrollbars,
|
||||
} from "@/stores/settings";
|
||||
|
||||
import mdiThemeLightDark from "~icons/mdi/theme-light-dark";
|
||||
import mdiWhiteBalanceSunny from "~icons/mdi/white-balance-sunny";
|
||||
import mdiWeatherNight from "~icons/mdi/weather-night";
|
||||
import mdiFormatLineSpacing from "~icons/mdi/format-line-spacing";
|
||||
import mdiClockOutline from "~icons/mdi/clock-outline";
|
||||
import mdiWrap from "~icons/mdi/wrap";
|
||||
import mdiEyeOutline from "~icons/mdi/eye-outline";
|
||||
import mdiFormatListBulleted from "~icons/mdi/format-list-bulleted";
|
||||
import mdiUnfoldMoreHorizontal from "~icons/mdi/unfold-more-horizontal";
|
||||
import mdiCogOutline from "~icons/mdi/cog-outline";
|
||||
import carbonRestart from "~icons/carbon/restart";
|
||||
import mdiStop from "~icons/mdi/stop";
|
||||
import mdiPlay from "~icons/mdi/play";
|
||||
import mdiDownload from "~icons/mdi/download";
|
||||
|
||||
export type CommandSection = "container" | "settings" | "navigation";
|
||||
|
||||
export type Command = {
|
||||
id: string;
|
||||
title: string;
|
||||
section: CommandSection;
|
||||
icon: Component;
|
||||
keywords?: string;
|
||||
perform: () => unknown;
|
||||
};
|
||||
|
||||
// Central registry for the Cmd+K command palette. Commands are recomputed on
|
||||
// every access so context-sensitive entries (container actions, current
|
||||
// toggle labels) stay in sync with the route and settings.
|
||||
export function useCommands() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
const currentId = computed(() =>
|
||||
route?.name === "/container/[id]" && typeof route.params.id === "string" ? route.params.id : "",
|
||||
);
|
||||
// Null-safe: containerStore.currentContainer is a stubbed action under
|
||||
// @pinia/testing, so guard against it being absent.
|
||||
const currentContainerRef = containerStore.currentContainer?.(currentId);
|
||||
const currentContainer = computed(() => currentContainerRef?.value as Container | undefined);
|
||||
|
||||
// Bound to the current container. The cast is safe because the action
|
||||
// handlers only read container.value when invoked, and container commands are
|
||||
// only pushed into the list when currentContainer is truthy — so the handlers
|
||||
// never run against an undefined container.
|
||||
const { start, stop, restart, update } = useContainerActions(currentContainer as Ref<Container>);
|
||||
|
||||
const commands = computed<Command[]>(() => {
|
||||
const list: Command[] = [];
|
||||
|
||||
const container = currentContainer.value;
|
||||
if (container && config.enableActions) {
|
||||
const name = container.name;
|
||||
list.push({
|
||||
id: "container.restart",
|
||||
section: "container",
|
||||
icon: carbonRestart,
|
||||
title: t("command-palette.restart-container", { name }),
|
||||
keywords: "restart reboot",
|
||||
perform: restart,
|
||||
});
|
||||
if (container.state === "running") {
|
||||
list.push({
|
||||
id: "container.stop",
|
||||
section: "container",
|
||||
icon: mdiStop,
|
||||
title: t("command-palette.stop-container", { name }),
|
||||
keywords: "stop kill halt",
|
||||
perform: stop,
|
||||
});
|
||||
} else {
|
||||
list.push({
|
||||
id: "container.start",
|
||||
section: "container",
|
||||
icon: mdiPlay,
|
||||
title: t("command-palette.start-container", { name }),
|
||||
keywords: "start run",
|
||||
perform: start,
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
id: "container.update",
|
||||
section: "container",
|
||||
icon: mdiDownload,
|
||||
title: t("command-palette.update-container", { name }),
|
||||
keywords: "update pull recreate upgrade",
|
||||
perform: update,
|
||||
});
|
||||
}
|
||||
|
||||
list.push(
|
||||
// lightTheme is tri-state, so expose each value as its own command rather
|
||||
// than a single toggle — that keeps "auto" (follow OS) reachable and makes
|
||||
// the target theme explicit instead of depending on the current state.
|
||||
{
|
||||
id: "settings.theme-auto",
|
||||
section: "settings",
|
||||
icon: mdiThemeLightDark,
|
||||
title: t("command-palette.theme-auto"),
|
||||
keywords: "theme auto system color mode appearance",
|
||||
perform: () => (lightTheme.value = "auto"),
|
||||
},
|
||||
{
|
||||
id: "settings.theme-light",
|
||||
section: "settings",
|
||||
icon: mdiWhiteBalanceSunny,
|
||||
title: t("command-palette.theme-light"),
|
||||
keywords: "theme light color mode appearance",
|
||||
perform: () => (lightTheme.value = "light"),
|
||||
},
|
||||
{
|
||||
id: "settings.theme-dark",
|
||||
section: "settings",
|
||||
icon: mdiWeatherNight,
|
||||
title: t("command-palette.theme-dark"),
|
||||
keywords: "theme dark color mode appearance",
|
||||
perform: () => (lightTheme.value = "dark"),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-compact",
|
||||
section: "settings",
|
||||
icon: mdiFormatLineSpacing,
|
||||
title: t("command-palette.toggle-compact"),
|
||||
keywords: "compact density spacing",
|
||||
perform: () => (compact.value = !compact.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-timestamps",
|
||||
section: "settings",
|
||||
icon: mdiClockOutline,
|
||||
title: t("command-palette.toggle-timestamps"),
|
||||
keywords: "timestamp time date",
|
||||
perform: () => (showTimestamp.value = !showTimestamp.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-soft-wrap",
|
||||
section: "settings",
|
||||
icon: mdiWrap,
|
||||
title: t("command-palette.toggle-soft-wrap"),
|
||||
keywords: "wrap soft line",
|
||||
perform: () => (softWrap.value = !softWrap.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-stopped",
|
||||
section: "settings",
|
||||
icon: mdiEyeOutline,
|
||||
title: t("command-palette.toggle-stopped"),
|
||||
keywords: "stopped hidden all containers exited",
|
||||
perform: () => (showAllContainers.value = !showAllContainers.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-std",
|
||||
section: "settings",
|
||||
icon: mdiFormatListBulleted,
|
||||
title: t("command-palette.toggle-std"),
|
||||
keywords: "stdout stderr std labels stream",
|
||||
perform: () => (showStd.value = !showStd.value),
|
||||
},
|
||||
{
|
||||
id: "settings.toggle-scrollbars",
|
||||
section: "settings",
|
||||
icon: mdiUnfoldMoreHorizontal,
|
||||
title: t("command-palette.toggle-scrollbars"),
|
||||
keywords: "scrollbar smaller thin",
|
||||
perform: () => (smallerScrollbars.value = !smallerScrollbars.value),
|
||||
},
|
||||
{
|
||||
id: "navigation.settings",
|
||||
section: "navigation",
|
||||
icon: mdiCogOutline,
|
||||
title: t("command-palette.open-settings"),
|
||||
keywords: "settings preferences options config",
|
||||
perform: () => router.push("/settings"),
|
||||
},
|
||||
);
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
// Commands shown before the user types anything: the context-sensitive
|
||||
// container actions so e.g. Restart is one keystroke away on a container page.
|
||||
const contextCommands = computed(() => commands.value.filter((c) => c.section === "container"));
|
||||
|
||||
return { commands, contextCommands };
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function useDownloadUrl(
|
||||
levels: Ref<Set<string>>,
|
||||
name?: Ref<string> | ComputedRef<string> | string,
|
||||
) {
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
const { debouncedSearchFilter, inverseFilter } = useSearchFilter();
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -20,6 +20,7 @@ export function useDownloadUrl(
|
||||
// Add filter if search is active
|
||||
if (debouncedSearchFilter.value) {
|
||||
params.append("filter", debouncedSearchFilter.value);
|
||||
if (inverseFilter.value) params.append("inverse", "true");
|
||||
}
|
||||
|
||||
// Add levels (multiple values) only if filtered
|
||||
|
||||
@@ -16,8 +16,9 @@ import { Service, Stack } from "@/models/Stack";
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
import { parseMessage } from "@/composable/loadBetween";
|
||||
import { useLogLoader } from "@/composable/logLoader";
|
||||
import { parseEventData } from "@/utils/events";
|
||||
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
const { isSearching, debouncedSearchFilter, inverseFilter } = useSearchFilter();
|
||||
|
||||
export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`);
|
||||
@@ -56,15 +57,23 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
|
||||
}
|
||||
|
||||
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
|
||||
const labels = computed(() => `namespace:${namespace.value.name}`);
|
||||
const labels = computed(() => `@k8s.namespace:${namespace.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
|
||||
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
|
||||
export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
|
||||
const labels = computed(() => `${owner.value.label}:true`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
}
|
||||
|
||||
export type SearchStatus = {
|
||||
active: boolean;
|
||||
done: boolean;
|
||||
matches: number;
|
||||
scannedTo?: string;
|
||||
reason?: "capped" | "exhausted";
|
||||
};
|
||||
|
||||
export type LogStreamSource = ReturnType<typeof useLogStream>;
|
||||
|
||||
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
@@ -73,6 +82,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
|
||||
const { paused: scrollingPaused } = useScrollContext();
|
||||
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
|
||||
let initial = true;
|
||||
@@ -81,7 +91,10 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
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);
|
||||
if (isSearching.value) {
|
||||
params.append("filter", debouncedSearchFilter.value);
|
||||
if (inverseFilter.value) params.append("inverse", "true");
|
||||
}
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
@@ -155,13 +168,14 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
initial = true;
|
||||
searchStatus.value = { active: isSearching.value, done: false, matches: 0 };
|
||||
es = new EventSource(urlWithParams.value);
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as {
|
||||
const event = parseEventData<{
|
||||
actorId: string;
|
||||
name: "container-stopped" | "container-started";
|
||||
time: string;
|
||||
};
|
||||
}>(e);
|
||||
const containerEvent = new ContainerEventLogEntry(
|
||||
event.name == "container-started" ? "Container started" : "Container stopped",
|
||||
event.actorId,
|
||||
@@ -175,11 +189,27 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
});
|
||||
|
||||
es.addEventListener("logs-backfill", (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data) as LogEvent[];
|
||||
const data = parseEventData<LogEvent[]>(e);
|
||||
const logs = data.map((e) => asLogEntry(e));
|
||||
messages.value = [...logs, ...messages.value];
|
||||
});
|
||||
|
||||
es.addEventListener("search-status", (e) => {
|
||||
const data = parseEventData<{
|
||||
scannedTo: string;
|
||||
matches: number;
|
||||
done: boolean;
|
||||
reason?: "capped" | "exhausted";
|
||||
}>(e);
|
||||
searchStatus.value = {
|
||||
active: !data.done,
|
||||
done: data.done,
|
||||
matches: data.matches,
|
||||
scannedTo: data.scannedTo,
|
||||
reason: data.reason,
|
||||
};
|
||||
});
|
||||
|
||||
es.onmessage = (e) => {
|
||||
if (e.data) {
|
||||
buffer.value = [...buffer.value, parseMessage(e.data)];
|
||||
@@ -211,5 +241,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
searchStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete";
|
||||
|
||||
export interface ExprEditorOptions {
|
||||
parent: HTMLElement;
|
||||
@@ -44,9 +44,9 @@ export function createContainerHints(
|
||||
{ label: '"healthy"', detail: "health value", type: "string" },
|
||||
{ label: '"unhealthy"', detail: "health value", type: "string" },
|
||||
{ label: '"none"', detail: "health value", type: "string" },
|
||||
...containerNames.map((name) => ({ label: `"${name}"`, detail: "container name", type: "string" }) as Completion),
|
||||
...imageNames.map((image) => ({ label: `"${image}"`, detail: "image name", type: "string" }) as Completion),
|
||||
...hostNames.map((host) => ({ label: `"${host}"`, detail: "host name", type: "string" }) as Completion),
|
||||
...containerNames.map((name): Completion => ({ label: `"${name}"`, detail: "container name", type: "string" })),
|
||||
...imageNames.map((image): Completion => ({ label: `"${image}"`, detail: "image name", type: "string" })),
|
||||
...hostNames.map((host): Completion => ({ label: `"${host}"`, detail: "host name", type: "string" })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createLogHints(messageKeys?: string[]): Completion[] {
|
||||
{ label: "timestamp", detail: "unix timestamp", type: "property" },
|
||||
{ label: "id", detail: "log entry ID", type: "property" },
|
||||
...(messageKeys ?? []).map(
|
||||
(key) => ({ label: `message.${key}`, detail: "message field", type: "property" }) as Completion,
|
||||
(key): Completion => ({ label: `message.${key}`, detail: "message field", type: "property" }),
|
||||
),
|
||||
...exprOperators,
|
||||
{ label: '"error"', detail: "level value", type: "string" },
|
||||
@@ -80,6 +80,11 @@ export function createMetricHints(): Completion[] {
|
||||
{ label: "cpu", detail: "CPU usage percent", type: "property" },
|
||||
{ label: "memory", detail: "memory usage percent", type: "property" },
|
||||
{ label: "memoryUsage", detail: "memory usage bytes", type: "property" },
|
||||
{ label: "mounts", detail: "list of container mounts with free-space info", type: "property" },
|
||||
{ label: ".usedPercent", detail: "mount field: % of mount used", type: "property" },
|
||||
{ label: ".availableBytes", detail: "mount field: free bytes on mount", type: "property" },
|
||||
{ label: ".destination", detail: "mount field: in-container mount path", type: "property" },
|
||||
{ label: "any(mounts, ...)", detail: "true if any mount matches the predicate", type: "keyword" },
|
||||
...exprOperators,
|
||||
{ label: ">", detail: "greater than", type: "operator" },
|
||||
{ label: "<", detail: "less than", type: "operator" },
|
||||
@@ -88,6 +93,12 @@ export function createMetricHints(): Completion[] {
|
||||
{ label: "cpu > 80", detail: "CPU over 80%", type: "text", boost: 10 },
|
||||
{ label: "memory > 90", detail: "memory over 90%", type: "text", boost: 10 },
|
||||
{ label: "cpu > 80 || memory > 90", detail: "CPU or memory high", type: "text", boost: 10 },
|
||||
{
|
||||
label: "any(mounts, .usedPercent >= 85)",
|
||||
detail: "alert when any mount is over 85% full",
|
||||
type: "text",
|
||||
boost: 10,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,6 +106,8 @@ export function createEventHints(): Completion[] {
|
||||
return [
|
||||
{ label: "name", detail: "event name", type: "property" },
|
||||
{ label: "attributes", detail: "event attributes map", type: "property" },
|
||||
{ label: 'attributes["healthStatus"]', detail: "healthy or unhealthy (health_status events)", type: "property" },
|
||||
{ label: 'attributes["exitCode"]', detail: "exit code (die events)", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: '"start"', detail: "container started", type: "string" },
|
||||
{ label: '"stop"', detail: "container stopped", type: "string" },
|
||||
@@ -103,12 +116,18 @@ export function createEventHints(): Completion[] {
|
||||
{ label: '"health_status"', detail: "health check changed", type: "string" },
|
||||
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
|
||||
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
|
||||
{
|
||||
label: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
|
||||
detail: "match unhealthy containers",
|
||||
type: "text",
|
||||
boost: 10,
|
||||
},
|
||||
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
function createAutocomplete(getHints: () => Completion[]) {
|
||||
return (context: any) => {
|
||||
function createAutocomplete(getHints: () => Completion[]): CompletionSource {
|
||||
return (context: CompletionContext) => {
|
||||
const word = context.matchBefore(/[\w"=!&|]+/);
|
||||
if (!word && !context.explicit) return null;
|
||||
|
||||
|
||||
@@ -8,16 +8,21 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
// Historical views are a fixed window around a log id, never a running search.
|
||||
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
|
||||
const container = toRef(() => historicalContainer.value.container);
|
||||
|
||||
const { streamConfig, levels, loadingMore } = useLoggingContext();
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
const { isSearching, debouncedSearchFilter, inverseFilter } = 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);
|
||||
if (isSearching.value) {
|
||||
params.append("filter", debouncedSearchFilter.value);
|
||||
if (inverseFilter.value) params.append("inverse", "true");
|
||||
}
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
@@ -122,5 +127,6 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
searchStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { useSearchFilter } from "./search";
|
||||
|
||||
describe("useSearchFilter", () => {
|
||||
// State is a module-level singleton, so reset before each test.
|
||||
beforeEach(() => {
|
||||
useSearchFilter().resetSearch();
|
||||
});
|
||||
|
||||
test("isValidQuery reflects regex validity", () => {
|
||||
const { searchQueryFilter, isValidQuery } = useSearchFilter();
|
||||
searchQueryFilter.value = "foo.*";
|
||||
expect(isValidQuery.value).toBe(true);
|
||||
|
||||
searchQueryFilter.value = "[";
|
||||
expect(isValidQuery.value).toBe(false);
|
||||
});
|
||||
|
||||
test("toggleInverse flips the inverse flag", () => {
|
||||
const { inverseFilter, toggleInverse } = useSearchFilter();
|
||||
expect(inverseFilter.value).toBe(false);
|
||||
toggleInverse();
|
||||
expect(inverseFilter.value).toBe(true);
|
||||
toggleInverse();
|
||||
expect(inverseFilter.value).toBe(false);
|
||||
});
|
||||
|
||||
test("resetSearch clears query, visibility and inverse", () => {
|
||||
const { searchQueryFilter, showSearch, inverseFilter, toggleInverse, resetSearch } = useSearchFilter();
|
||||
searchQueryFilter.value = "abc";
|
||||
showSearch.value = true;
|
||||
toggleInverse();
|
||||
|
||||
resetSearch();
|
||||
|
||||
expect(searchQueryFilter.value).toBe("");
|
||||
expect(showSearch.value).toBe(false);
|
||||
expect(inverseFilter.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const searchQueryFilter = ref<string>("");
|
||||
const debouncedSearchFilter = refDebounced(searchQueryFilter);
|
||||
const showSearch = ref(false);
|
||||
const inverseFilter = ref(false);
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get("search") !== null && searchParams.get("search") !== "") {
|
||||
@@ -10,6 +11,11 @@ if (searchParams.get("search") !== null && searchParams.get("search") !== "") {
|
||||
function resetSearch() {
|
||||
searchQueryFilter.value = "";
|
||||
showSearch.value = false;
|
||||
inverseFilter.value = false;
|
||||
}
|
||||
|
||||
function toggleInverse() {
|
||||
inverseFilter.value = !inverseFilter.value;
|
||||
}
|
||||
|
||||
const isSearching = computed(() => showSearch.value && debouncedSearchFilter.value !== "");
|
||||
@@ -31,5 +37,7 @@ export function useSearchFilter() {
|
||||
showSearch,
|
||||
resetSearch,
|
||||
isSearching,
|
||||
inverseFilter,
|
||||
toggleInverse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComplexLogEntry, type LogMessage, type LogEntry } from "@/models/LogEntry";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
|
||||
const { isSearching } = useSearchFilter();
|
||||
const { isSearching, inverseFilter } = useSearchFilter();
|
||||
function filteredPayload(messages: Ref<LogEntry<LogMessage>[]>) {
|
||||
return computed(() => {
|
||||
return messages.value
|
||||
@@ -14,7 +14,8 @@ export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
|
||||
})
|
||||
.filter((d) => {
|
||||
if (isSearching.value && d instanceof ComplexLogEntry) {
|
||||
return Object.values(d.message).some((v) => JSON.stringify(v)?.includes("<mark>"));
|
||||
const hasMark = Object.values(d.message).some((v) => JSON.stringify(v)?.includes("<mark>"));
|
||||
return inverseFilter.value ? !hasMark : hasMark;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<div class="modal-box max-w-2xl overflow-visible! bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
@@ -52,7 +52,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// @ts-ignore - splitpanes types are not available
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { collapseNav } from "@/stores/settings";
|
||||
import SideDrawer from "@/components/common/SideDrawer.vue";
|
||||
@@ -101,7 +100,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
padding-top: var(--mobile-nav-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 7.4 KiB |
@@ -57,6 +57,24 @@
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
/* ANSI log palette tuned for legibility on the dark background */
|
||||
--ansi-black: oklch(45% 0 0);
|
||||
--ansi-red: oklch(65% 0.2 25);
|
||||
--ansi-green: oklch(72% 0.16 150);
|
||||
--ansi-yellow: oklch(80% 0.13 85);
|
||||
--ansi-blue: oklch(70% 0.14 250);
|
||||
--ansi-magenta: oklch(68% 0.19 330);
|
||||
--ansi-cyan: oklch(75% 0.11 200);
|
||||
--ansi-white: oklch(89% 0 0);
|
||||
--ansi-bright-black: oklch(60% 0 0);
|
||||
--ansi-bright-red: oklch(72% 0.2 25);
|
||||
--ansi-bright-green: oklch(82% 0.17 150);
|
||||
--ansi-bright-yellow: oklch(88% 0.14 95);
|
||||
--ansi-bright-blue: oklch(78% 0.13 250);
|
||||
--ansi-bright-magenta: oklch(75% 0.19 330);
|
||||
--ansi-bright-cyan: oklch(85% 0.11 200);
|
||||
--ansi-bright-white: oklch(100% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
@@ -97,6 +115,24 @@
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
/* ANSI log palette tuned for legibility on the light background */
|
||||
--ansi-black: oklch(25% 0 0);
|
||||
--ansi-red: oklch(52% 0.2 25);
|
||||
--ansi-green: oklch(52% 0.15 150);
|
||||
--ansi-yellow: oklch(60% 0.13 75);
|
||||
--ansi-blue: oklch(50% 0.17 255);
|
||||
--ansi-magenta: oklch(50% 0.2 330);
|
||||
--ansi-cyan: oklch(55% 0.1 210);
|
||||
--ansi-white: oklch(70% 0 0);
|
||||
--ansi-bright-black: oklch(45% 0 0);
|
||||
--ansi-bright-red: oklch(58% 0.21 25);
|
||||
--ansi-bright-green: oklch(58% 0.16 150);
|
||||
--ansi-bright-yellow: oklch(62% 0.13 85);
|
||||
--ansi-bright-blue: oklch(55% 0.16 255);
|
||||
--ansi-bright-magenta: oklch(56% 0.2 330);
|
||||
--ansi-bright-cyan: oklch(60% 0.1 210);
|
||||
--ansi-bright-white: oklch(85% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
@@ -120,6 +156,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* daisyUI darkens the ghost button's border on hover, which reads as a
|
||||
* near-black ring on the dark theme. Use a theme-aware base-content tint so the
|
||||
* hover ring stays visible and legible in both themes (light on dark, dark on
|
||||
* light) instead of a fixed near-black. */
|
||||
.btn-ghost:hover {
|
||||
border-color: color-mix(in oklab, var(--color-base-content) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
@utility menu-active {
|
||||
&,
|
||||
&:active {
|
||||
@@ -143,6 +187,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Reserve the document scrollbar gutter so the layout width stays constant when
|
||||
* a page-level scrollbar appears (e.g. once a container's logs load). Without
|
||||
* this, the width jump makes the percentage-based splitpanes recompute and the
|
||||
* sidebar visibly flicks. */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
@@ -150,6 +202,13 @@ body {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Height of the mobile top nav (logo row + border). The container status bar
|
||||
* sticks right below it and the log content is padded by the same amount, so
|
||||
* they stay locked together. The safe-area inset is already handled by body. */
|
||||
:root {
|
||||
--mobile-nav-height: 57px;
|
||||
}
|
||||
|
||||
[class*="shadow-"] {
|
||||
@apply shadow-base-content/8;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { Container, emptyStat, type Stat } from "./Container";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function makeContainer(
|
||||
overrides: { labels?: Record<string, string>; image?: string; command?: string; stats?: Stat[] } = {},
|
||||
) {
|
||||
return new Container(
|
||||
"id-1",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
overrides.image ?? "image",
|
||||
"display-name",
|
||||
overrides.command ?? "command",
|
||||
"localhost",
|
||||
overrides.labels ?? {},
|
||||
"running",
|
||||
0,
|
||||
0,
|
||||
overrides.stats ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
function makeStat(partial: Partial<Stat> = {}): Stat {
|
||||
return { ...emptyStat(), ...partial };
|
||||
}
|
||||
|
||||
describe("Container.namespace", () => {
|
||||
test("prefers dev.dozzle.group over all others", () => {
|
||||
const c = makeContainer({
|
||||
labels: {
|
||||
"dev.dozzle.group": "g",
|
||||
"coolify.projectName": "c",
|
||||
"com.docker.stack.namespace": "s",
|
||||
"com.docker.compose.project": "p",
|
||||
},
|
||||
});
|
||||
expect(c.namespace).toBe("g");
|
||||
});
|
||||
|
||||
test("falls back through coolify, stack, then compose", () => {
|
||||
expect(makeContainer({ labels: { "coolify.projectName": "c" } }).namespace).toBe("c");
|
||||
expect(makeContainer({ labels: { "com.docker.stack.namespace": "s" } }).namespace).toBe("s");
|
||||
expect(makeContainer({ labels: { "com.docker.compose.project": "p" } }).namespace).toBe("p");
|
||||
});
|
||||
|
||||
test("undefined when no grouping label is present", () => {
|
||||
expect(makeContainer().namespace).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container.name", () => {
|
||||
test("non-swarm returns the constructor name and respects the setter", () => {
|
||||
const c = makeContainer();
|
||||
expect(c.name).toBe("display-name");
|
||||
c.name = "renamed";
|
||||
expect(c.name).toBe("renamed");
|
||||
});
|
||||
|
||||
test("swarm strips task id and node id from the task name", () => {
|
||||
const c = makeContainer({
|
||||
labels: {
|
||||
"com.docker.swarm.service.id": "svc",
|
||||
"com.docker.swarm.task.name": "api.1.t1n0d3",
|
||||
"com.docker.swarm.task.id": "t1n0d3",
|
||||
"com.docker.swarm.node.id": "node99",
|
||||
},
|
||||
});
|
||||
expect(c.isSwarm).toBe(true);
|
||||
expect(c.name).toBe("api.1");
|
||||
expect(c.swarmId).toBe("t1n0d3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container.storageKey", () => {
|
||||
test("combines stripped image with command", () => {
|
||||
expect(makeContainer({ image: "nginx:1.25", command: "run" }).storageKey).toBe("nginx:run");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container.hostLabel", () => {
|
||||
test("resolves the host name from config", () => {
|
||||
expect(makeContainer().hostLabel).toBe("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container stats history", () => {
|
||||
test("pads history to 300 and seeds latest stat", () => {
|
||||
const a = makeStat({ cpu: 1 });
|
||||
const b = makeStat({ cpu: 2 });
|
||||
const c = makeContainer({ stats: [a, b] });
|
||||
expect(c.statsHistory).toHaveLength(300);
|
||||
expect(c.statsHistory.at(-1)).toEqual(b);
|
||||
expect(c.statsHistory.at(-2)).toEqual(a);
|
||||
expect(c.stat).toEqual(b);
|
||||
});
|
||||
|
||||
test("empty stats seed an empty stat", () => {
|
||||
const c = makeContainer();
|
||||
expect(c.statsHistory).toHaveLength(300);
|
||||
expect(c.stat).toEqual(emptyStat());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container.updateStat", () => {
|
||||
test("applies EMA (alpha 0.2) to cpu/memory and passes totals through", () => {
|
||||
const c = makeContainer();
|
||||
c.updateStat(makeStat({ cpu: 10, memory: 50, memoryUsage: 100, networkRxTotal: 5, diskWriteTotal: 8 }));
|
||||
|
||||
expect(c.stat.cpu).toBe(10);
|
||||
expect(c.movingAverage.cpu).toBeCloseTo(2, 10);
|
||||
expect(c.movingAverage.memory).toBeCloseTo(10, 10);
|
||||
expect(c.movingAverage.memoryUsage).toBeCloseTo(20, 10);
|
||||
// totals are not averaged
|
||||
expect(c.movingAverage.networkRxTotal).toBe(5);
|
||||
expect(c.movingAverage.diskWriteTotal).toBe(8);
|
||||
});
|
||||
|
||||
test("EMA folds in the previous moving average on each update", () => {
|
||||
const c = makeContainer();
|
||||
c.updateStat(makeStat({ cpu: 10, memory: 50, memoryUsage: 100 }));
|
||||
c.updateStat(makeStat({ cpu: 10, memory: 50, memoryUsage: 100 }));
|
||||
|
||||
expect(c.movingAverage.cpu).toBeCloseTo(3.6, 10); // 0.2*10 + 0.8*2
|
||||
expect(c.movingAverage.memory).toBeCloseTo(18, 10); // 0.2*50 + 0.8*10
|
||||
expect(c.movingAverage.memoryUsage).toBeCloseTo(36, 10); // 0.2*100 + 0.8*20
|
||||
});
|
||||
|
||||
test("history stays capped at 300 with the newest stat last", () => {
|
||||
const c = makeContainer();
|
||||
const latest = makeStat({ cpu: 42 });
|
||||
c.updateStat(latest);
|
||||
expect(c.statsHistory).toHaveLength(300);
|
||||
expect(c.statsHistory.at(-1)).toEqual(latest);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,25 @@
|
||||
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
|
||||
import type {
|
||||
ContainerHealth,
|
||||
ContainerJson,
|
||||
ContainerMount,
|
||||
ContainerStat,
|
||||
ContainerState,
|
||||
MountStat,
|
||||
} from "@/types/Container";
|
||||
import { Ref } from "vue";
|
||||
|
||||
export type Stat = Omit<ContainerStat, "id">;
|
||||
|
||||
export const emptyStat = (): Stat => ({
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
memoryUsage: 0,
|
||||
networkRxTotal: 0,
|
||||
networkTxTotal: 0,
|
||||
diskReadTotal: 0,
|
||||
diskWriteTotal: 0,
|
||||
});
|
||||
|
||||
const hosts = computed(() =>
|
||||
config.hosts.reduce(
|
||||
(acc, item) => {
|
||||
@@ -33,6 +50,9 @@ export class Container {
|
||||
private readonly _statsHistory: Ref<Stat[]>;
|
||||
private readonly movingAverageStat: Ref<Stat>;
|
||||
|
||||
public mounts: ContainerMount[];
|
||||
public mountStats: Record<string, MountStat>;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly created: Date,
|
||||
@@ -50,8 +70,12 @@ export class Container {
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
public isNew: boolean = false,
|
||||
mounts: ContainerMount[] = [],
|
||||
mountStats: Record<string, MountStat> = {},
|
||||
) {
|
||||
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
|
||||
this.mounts = mounts;
|
||||
this.mountStats = mountStats;
|
||||
const defaultStat = emptyStat();
|
||||
this._stat = ref(stats.at(-1) || defaultStat);
|
||||
const recentStats = stats.slice(-300);
|
||||
const padding = Array(300 - recentStats.length).fill(defaultStat);
|
||||
@@ -140,6 +164,8 @@ export class Container {
|
||||
memoryUsage: alpha * stat.memoryUsage + (1 - alpha) * prev.memoryUsage,
|
||||
networkRxTotal: stat.networkRxTotal,
|
||||
networkTxTotal: stat.networkTxTotal,
|
||||
diskReadTotal: stat.diskReadTotal,
|
||||
diskWriteTotal: stat.diskWriteTotal,
|
||||
};
|
||||
if (isRef(this.movingAverageStat)) {
|
||||
this.movingAverageStat.value = newEma;
|
||||
@@ -148,6 +174,10 @@ export class Container {
|
||||
}
|
||||
}
|
||||
|
||||
public updateMountStats(mountStats: Record<string, MountStat>) {
|
||||
this.mountStats = mountStats ?? {};
|
||||
}
|
||||
|
||||
static fromJSON(c: ContainerJson): Container {
|
||||
return new Container(
|
||||
c.id,
|
||||
@@ -165,6 +195,9 @@ export class Container {
|
||||
c.stats ?? [],
|
||||
c.group,
|
||||
c.health,
|
||||
false,
|
||||
c.mounts ?? [],
|
||||
c.mountStats ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
asLogEntry,
|
||||
ComplexLogEntry,
|
||||
GroupedLogEntry,
|
||||
SimpleLogEntry,
|
||||
SkippedLogsEntry,
|
||||
type LogEvent,
|
||||
} from "./LogEntry";
|
||||
|
||||
function event(overrides: Partial<LogEvent> = {}): LogEvent {
|
||||
return {
|
||||
t: "single",
|
||||
m: "hello",
|
||||
ts: 1_700_000_000_000,
|
||||
id: 1,
|
||||
l: "info",
|
||||
s: "stdout",
|
||||
c: "container-1",
|
||||
rm: "raw",
|
||||
...overrides,
|
||||
} as LogEvent;
|
||||
}
|
||||
|
||||
describe("asLogEntry dispatch", () => {
|
||||
test("single -> SimpleLogEntry", () => {
|
||||
const entry = asLogEntry(event({ t: "single", m: "a line" }));
|
||||
expect(entry).toBeInstanceOf(SimpleLogEntry);
|
||||
expect(entry.message).toBe("a line");
|
||||
});
|
||||
|
||||
test("group -> GroupedLogEntry with fragments mapped to strings", () => {
|
||||
const entry = asLogEntry(event({ t: "group", m: [{ m: "line1" }, { m: "line2" }] }));
|
||||
expect(entry).toBeInstanceOf(GroupedLogEntry);
|
||||
expect(entry.message).toEqual(["line1", "line2"]);
|
||||
});
|
||||
|
||||
test("complex -> ComplexLogEntry", () => {
|
||||
const entry = asLogEntry(event({ t: "complex", m: { a: 1 } }));
|
||||
expect(entry).toBeInstanceOf(ComplexLogEntry);
|
||||
});
|
||||
|
||||
test("unknown type falls back to SimpleLogEntry", () => {
|
||||
const entry = asLogEntry(event({ t: "mystery" as any, m: "x" }));
|
||||
expect(entry).toBeInstanceOf(SimpleLogEntry);
|
||||
});
|
||||
|
||||
test("carries id, container, level and date from the event", () => {
|
||||
const entry = asLogEntry(event({ ts: 1_700_000_000_000, id: 7, c: "abc", l: "warn" }));
|
||||
expect(entry.id).toBe(7);
|
||||
expect(entry.containerID).toBe("abc");
|
||||
expect(entry.level).toBe("warn");
|
||||
expect(entry.date.getTime()).toBe(1_700_000_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("std normalization", () => {
|
||||
test("unknown becomes stderr", () => {
|
||||
expect(asLogEntry(event({ s: "unknown" })).std).toBe("stderr");
|
||||
});
|
||||
|
||||
test("missing becomes stderr", () => {
|
||||
expect(asLogEntry(event({ s: undefined as any })).std).toBe("stderr");
|
||||
});
|
||||
|
||||
test("stdout and stderr are preserved", () => {
|
||||
expect(asLogEntry(event({ s: "stdout" })).std).toBe("stdout");
|
||||
expect(asLogEntry(event({ s: "stderr" })).std).toBe("stderr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplexLogEntry filtering", () => {
|
||||
const message = { a: { b: 1 }, c: 2 };
|
||||
|
||||
test("empty visibleKeys returns the fully flattened object", () => {
|
||||
const entry = new ComplexLogEntry(message, "c", 1, new Date(), "info", "stdout", "raw", ref(new Map()));
|
||||
expect(entry.message).toEqual({ "a.b": 1, c: 2 });
|
||||
expect(entry.unfilteredMessage).toEqual(message);
|
||||
});
|
||||
|
||||
test("disabled keys are dropped and enabled keys come first", () => {
|
||||
const visibleKeys = ref(
|
||||
new Map<string[], boolean>([
|
||||
[["c"], true],
|
||||
[["a", "b"], false],
|
||||
]),
|
||||
);
|
||||
const entry = new ComplexLogEntry(message, "c", 1, new Date(), "info", "stdout", "raw", visibleKeys);
|
||||
expect(entry.message).toEqual({ c: 2 });
|
||||
});
|
||||
|
||||
test("enabled keys are ordered before remaining keys", () => {
|
||||
const visibleKeys = ref(new Map<string[], boolean>([[["a", "b"], true]]));
|
||||
const entry = new ComplexLogEntry(message, "c", 1, new Date(), "info", "stdout", "raw", visibleKeys);
|
||||
expect(Object.keys(entry.message)).toEqual(["a.b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkippedLogsEntry", () => {
|
||||
function simple(id: number) {
|
||||
return new SimpleLogEntry(`m${id}`, "c", id, new Date(), "info", "stdout", `m${id}`);
|
||||
}
|
||||
|
||||
test("renders the running skipped count and accumulates more", () => {
|
||||
const entry = new SkippedLogsEntry(new Date(), 3, simple(1), simple(2), async () => {});
|
||||
expect(entry.message).toBe("Skipped 3 entries");
|
||||
|
||||
const newLast = simple(5);
|
||||
entry.addSkippedEntries(2, newLast);
|
||||
expect(entry.message).toBe("Skipped 5 entries");
|
||||
expect(entry.totalSkipped).toBe(5);
|
||||
expect(entry.lastSkippedLog).toBe(newLast);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const k8sStore = useK8sStore();
|
||||
const { owners } = storeToRefs(k8sStore);
|
||||
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
|
||||
const ownerKey = computed(() => {
|
||||
try {
|
||||
return decodeURIComponent(String(route.params.name));
|
||||
} catch {
|
||||
return String(route.params.name);
|
||||
}
|
||||
});
|
||||
const owner = computed(() => owners.value.find((o) => o.key === ownerKey.value));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
|
||||
+35
-18
@@ -3,6 +3,7 @@ import { Ref, UnwrapNestedRefs } from "vue";
|
||||
import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import i18n from "@/modules/i18n";
|
||||
import { parseEventData } from "@/utils/events";
|
||||
import { Host } from "./hosts";
|
||||
|
||||
const { showToast, removeToast } = useToast();
|
||||
@@ -31,29 +32,32 @@ export const useContainerStore = defineStore("container", () => {
|
||||
return containers.value.filter(filter);
|
||||
});
|
||||
|
||||
let errorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function connect() {
|
||||
es?.close();
|
||||
ready.value = false;
|
||||
es = new EventSource(withBase("/api/events/stream"));
|
||||
es.addEventListener("error", (e) => {
|
||||
if (es?.readyState === EventSource.CONNECTING) {
|
||||
showToast(
|
||||
{
|
||||
id: "events-stream",
|
||||
message: t("error.events-stream.message"),
|
||||
title: t("error.events-stream.title"),
|
||||
type: "error",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
if (es?.readyState === EventSource.CONNECTING && errorTimer === null) {
|
||||
errorTimer = setTimeout(() => {
|
||||
errorTimer = null;
|
||||
showToast(
|
||||
{
|
||||
id: "events-stream",
|
||||
message: t("error.events-stream.message"),
|
||||
title: t("error.events-stream.title"),
|
||||
type: "error",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("containers-changed", (e: Event) =>
|
||||
updateContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[]),
|
||||
);
|
||||
es.addEventListener("containers-changed", (e) => updateContainers(parseEventData<ContainerJson[]>(e)));
|
||||
es.addEventListener("container-stat", (e) => {
|
||||
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
|
||||
const stat = parseEventData<ContainerStat>(e);
|
||||
const container = allContainersById.value[stat.id] as unknown as UnwrapNestedRefs<Container>;
|
||||
if (container) {
|
||||
const { id, ...rest } = stat;
|
||||
@@ -61,7 +65,7 @@ export const useContainerStore = defineStore("container", () => {
|
||||
}
|
||||
});
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string; time: string };
|
||||
const event = parseEventData<{ actorId: string; name: string; time: string }>(e);
|
||||
const container = allContainersById.value[event.actorId];
|
||||
if (container) {
|
||||
switch (event.name) {
|
||||
@@ -72,12 +76,18 @@ export const useContainerStore = defineStore("container", () => {
|
||||
case "destroy":
|
||||
container.state = "deleted";
|
||||
break;
|
||||
case "pause":
|
||||
container.state = "paused";
|
||||
break;
|
||||
case "unpause":
|
||||
container.state = "running";
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("container-updated", (e) => {
|
||||
const container = JSON.parse((e as MessageEvent).data) as ContainerJson;
|
||||
const container = parseEventData<ContainerJson>(e);
|
||||
const existing = allContainersById.value[container.id];
|
||||
if (existing) {
|
||||
existing.name = container.name;
|
||||
@@ -85,16 +95,19 @@ export const useContainerStore = defineStore("container", () => {
|
||||
existing.health = container.health;
|
||||
existing.startedAt = new Date(container.startedAt);
|
||||
existing.finishedAt = new Date(container.finishedAt);
|
||||
if (container.mountStats) {
|
||||
existing.updateMountStats(container.mountStats);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("update-host", (e) => {
|
||||
const host = JSON.parse((e as MessageEvent).data) as Host;
|
||||
const host = parseEventData<Host>(e);
|
||||
updateHost(host);
|
||||
});
|
||||
|
||||
es.addEventListener("container-health", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; health: ContainerHealth };
|
||||
const event = parseEventData<{ actorId: string; health: ContainerHealth }>(e);
|
||||
const container = allContainersById.value[event.actorId];
|
||||
if (container) {
|
||||
container.health = event.health;
|
||||
@@ -102,6 +115,10 @@ export const useContainerStore = defineStore("container", () => {
|
||||
});
|
||||
|
||||
es.onopen = () => {
|
||||
if (errorTimer !== null) {
|
||||
clearTimeout(errorTimer);
|
||||
errorTimer = null;
|
||||
}
|
||||
removeToast("events-stream");
|
||||
if (containers.value.length > 0) {
|
||||
containers.value = [];
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Host = {
|
||||
endpoint: string;
|
||||
available: boolean;
|
||||
dockerVersion: string;
|
||||
runtime?: "docker" | "podman";
|
||||
agentVersion: string;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { Container } from "@/models/Container";
|
||||
import { getK8sOwnerRefs, groupK8sOwners, ownerMembershipLabel } from "./k8s";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function makeContainer(id: string, labels: Record<string, string>) {
|
||||
return new Container(
|
||||
id,
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
id,
|
||||
"command",
|
||||
"localhost",
|
||||
labels,
|
||||
"running",
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
describe("getK8sOwnerRefs", () => {
|
||||
test("parses indexed owner-chain labels", () => {
|
||||
const container = makeContainer("api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "2",
|
||||
"@k8s.owner.0.kind": "ReplicaSet",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api-6f88b977f4",
|
||||
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
|
||||
"@k8s.owner.1.kind": "Deployment",
|
||||
"@k8s.owner.1.namespace": "default",
|
||||
"@k8s.owner.1.name": "api",
|
||||
"@k8s.owner.1.key": "Deployment~default~api",
|
||||
});
|
||||
|
||||
expect(getK8sOwnerRefs(container)).toEqual([
|
||||
{
|
||||
key: "ReplicaSet~default~api-6f88b977f4",
|
||||
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
|
||||
kind: "ReplicaSet",
|
||||
name: "api-6f88b977f4",
|
||||
namespace: "default",
|
||||
},
|
||||
{
|
||||
key: "Deployment~default~api",
|
||||
label: ownerMembershipLabel("Deployment~default~api"),
|
||||
kind: "Deployment",
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to legacy immediate owner labels", () => {
|
||||
const container = makeContainer("api", {
|
||||
namespace: "default",
|
||||
"owner.kind": "ReplicaSet",
|
||||
"owner.name": "api-6f88b977f4",
|
||||
});
|
||||
|
||||
expect(getK8sOwnerRefs(container)).toEqual([
|
||||
{
|
||||
key: "ReplicaSet~default~api-6f88b977f4",
|
||||
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
|
||||
kind: "ReplicaSet",
|
||||
name: "api-6f88b977f4",
|
||||
namespace: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses kind for display and type for CRD identity", () => {
|
||||
const container = makeContainer("rollout-api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "1",
|
||||
"@k8s.owner.0.kind": "Rollout",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api",
|
||||
"@k8s.owner.0.key": "argoproj.io~v1alpha1~Rollout~default~api",
|
||||
});
|
||||
|
||||
expect(getK8sOwnerRefs(container)).toEqual([
|
||||
{
|
||||
key: "argoproj.io~v1alpha1~Rollout~default~api",
|
||||
label: ownerMembershipLabel("argoproj.io~v1alpha1~Rollout~default~api"),
|
||||
kind: "Rollout",
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupK8sOwners", () => {
|
||||
test("groups a container under every owner in its chain", () => {
|
||||
const container = makeContainer("api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "2",
|
||||
"@k8s.owner.0.kind": "ReplicaSet",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api-6f88b977f4",
|
||||
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
|
||||
"@k8s.owner.1.kind": "Deployment",
|
||||
"@k8s.owner.1.namespace": "default",
|
||||
"@k8s.owner.1.name": "api",
|
||||
"@k8s.owner.1.key": "Deployment~default~api",
|
||||
});
|
||||
|
||||
const owners = groupK8sOwners([container]);
|
||||
|
||||
expect(owners.map((owner) => owner.key).sort()).toEqual([
|
||||
"Deployment~default~api",
|
||||
"ReplicaSet~default~api-6f88b977f4",
|
||||
]);
|
||||
expect(owners.every((owner) => owner.containers.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps same-name owners in different namespaces separate", () => {
|
||||
const owners = groupK8sOwners([
|
||||
makeContainer("default-api", {
|
||||
namespace: "default",
|
||||
"@k8s.owner.count": "1",
|
||||
"@k8s.owner.0.kind": "Deployment",
|
||||
"@k8s.owner.0.namespace": "default",
|
||||
"@k8s.owner.0.name": "api",
|
||||
"@k8s.owner.0.key": "Deployment~default~api",
|
||||
}),
|
||||
makeContainer("prod-api", {
|
||||
namespace: "prod",
|
||||
"@k8s.owner.count": "1",
|
||||
"@k8s.owner.0.kind": "Deployment",
|
||||
"@k8s.owner.0.namespace": "prod",
|
||||
"@k8s.owner.0.name": "api",
|
||||
"@k8s.owner.0.key": "Deployment~prod~api",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(owners.map((owner) => owner.key).sort()).toEqual(["Deployment~default~api", "Deployment~prod~api"]);
|
||||
});
|
||||
});
|
||||
+65
-33
@@ -2,6 +2,14 @@ import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
export type K8sOwnerRef = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export class K8sNamespace {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -22,6 +30,9 @@ export class K8sOwner {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly kind: string,
|
||||
public readonly namespaceName: string | undefined,
|
||||
public readonly key: string,
|
||||
public readonly label: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
|
||||
@@ -32,6 +43,54 @@ export class K8sOwner {
|
||||
}
|
||||
}
|
||||
|
||||
export function ownerMembershipLabel(key: string) {
|
||||
const bytes = new TextEncoder().encode(key);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return `@k8s.owner.key.${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export function getK8sOwnerRefs(container: Container): K8sOwnerRef[] {
|
||||
const count = Number(container.labels["@k8s.owner.count"] ?? 0);
|
||||
if (count > 0) {
|
||||
const owners: K8sOwnerRef[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prefix = `@k8s.owner.${i}.`;
|
||||
const kind = container.labels[`${prefix}kind`];
|
||||
const name = container.labels[`${prefix}name`];
|
||||
const namespace = container.labels[`${prefix}namespace`];
|
||||
if (!kind || !name) continue;
|
||||
const key = container.labels[`${prefix}key`] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||
owners.push({ key, label: ownerMembershipLabel(key), kind, name, namespace });
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
// "~" matches the backend owner-key delimiter: URL-safe and invalid in Kubernetes names.
|
||||
const kind = container.labels["owner.kind"];
|
||||
const name = container.labels["owner.name"];
|
||||
if (!kind || !name) return [];
|
||||
|
||||
const namespace = container.labels["namespace"];
|
||||
const key = container.labels["owner.key"] ?? `${kind}~${namespace ?? ""}~${name}`;
|
||||
return [{ key, label: ownerMembershipLabel(key), kind, name, namespace }];
|
||||
}
|
||||
|
||||
export function groupK8sOwners(containers: Container[]) {
|
||||
const ownerGroups: Record<string, { owner: K8sOwnerRef; containers: Container[] }> = {};
|
||||
for (const container of containers) {
|
||||
for (const owner of getK8sOwnerRefs(container)) {
|
||||
ownerGroups[owner.key] ||= { owner, containers: [] };
|
||||
ownerGroups[owner.key].containers.push(container);
|
||||
}
|
||||
}
|
||||
return Object.values(ownerGroups).map(({ owner, containers }) => {
|
||||
return new K8sOwner(owner.name, owner.kind, owner.namespace, owner.key, owner.label, containers);
|
||||
});
|
||||
}
|
||||
|
||||
export const useK8sStore = defineStore("k8s", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
@@ -50,24 +109,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
const newNamespaces: K8sNamespace[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(namespacedContainers)) {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of containers) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
}
|
||||
|
||||
const newOwners: K8sOwner[] = [];
|
||||
|
||||
for (const [key, containers] of Object.entries(ownerGroups)) {
|
||||
const [kind, name] = key.split(":");
|
||||
newOwners.push(new K8sOwner(name, kind, containers));
|
||||
}
|
||||
const newOwners = groupK8sOwners(containers);
|
||||
|
||||
if (newOwners.length === 0) continue;
|
||||
|
||||
@@ -75,7 +117,7 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
new K8sNamespace(
|
||||
name,
|
||||
containers,
|
||||
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
newOwners.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -83,33 +125,23 @@ export const useK8sStore = defineStore("k8s", () => {
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
const ownerGroups: Record<string, Container[]> = {};
|
||||
const containersWithoutNamespace: Container[] = [];
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const ownerKind = container.labels["owner.kind"];
|
||||
const ownerName = container.labels["owner.name"];
|
||||
const namespace = container.labels["namespace"];
|
||||
|
||||
if (ownerKind === undefined || ownerName === undefined) continue;
|
||||
if (namespace) {
|
||||
// Skip containers that are already part of a namespace
|
||||
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
|
||||
if (hasNamespace) continue;
|
||||
}
|
||||
|
||||
const key = `${ownerKind}:${ownerName}`;
|
||||
ownerGroups[key] ||= [];
|
||||
ownerGroups[key].push(container);
|
||||
containersWithoutNamespace.push(container);
|
||||
}
|
||||
|
||||
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
|
||||
|
||||
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
|
||||
const [kind, name] = key.split(":");
|
||||
return new K8sOwner(name, kind, containers);
|
||||
});
|
||||
const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
|
||||
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.key.localeCompare(b.key));
|
||||
});
|
||||
|
||||
const customGroups = computed(() => {
|
||||
|
||||
Vendored
+38
-2
@@ -20,8 +20,9 @@ import type {
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface TypesConfig {
|
||||
ParamParsers:
|
||||
| never
|
||||
_ParamParsers: {}
|
||||
RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
|
||||
_RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,102 +168,137 @@ declare module 'vue-router/auto-routes' {
|
||||
| '/'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/[...all].vue': {
|
||||
routes:
|
||||
| '/[...all]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'all'
|
||||
}
|
||||
'assets/pages/cloud/search.vue': {
|
||||
routes:
|
||||
| '/cloud/search'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/container/[id].vue': {
|
||||
routes:
|
||||
| '/container/[id]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'id'
|
||||
}
|
||||
'assets/pages/container/[id].time.[datetime].vue': {
|
||||
routes:
|
||||
| '/container/[id].time.[datetime]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'datetime'
|
||||
| 'id'
|
||||
}
|
||||
'assets/pages/group/[name].vue': {
|
||||
routes:
|
||||
| '/group/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/host/[id].vue': {
|
||||
routes:
|
||||
| '/host/[id]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'id'
|
||||
}
|
||||
'assets/pages/host-group/[name].vue': {
|
||||
routes:
|
||||
| '/host-group/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/login.vue': {
|
||||
routes:
|
||||
| '/login'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/merged/[ids].vue': {
|
||||
routes:
|
||||
| '/merged/[ids]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'ids'
|
||||
}
|
||||
'assets/pages/namespace/[name].vue': {
|
||||
routes:
|
||||
| '/namespace/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/notifications.vue': {
|
||||
routes:
|
||||
| '/notifications'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/owner/[name].vue': {
|
||||
routes:
|
||||
| '/owner/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/service/[name].vue': {
|
||||
routes:
|
||||
| '/service/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
'assets/pages/settings.vue': {
|
||||
routes:
|
||||
| '/settings'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/show.vue': {
|
||||
routes:
|
||||
| '/show'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| never
|
||||
}
|
||||
'assets/pages/stack/[name].vue': {
|
||||
routes:
|
||||
| '/stack/[name]'
|
||||
views:
|
||||
| never
|
||||
pathParamNames:
|
||||
| 'name'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+20
@@ -5,6 +5,24 @@ export interface ContainerStat {
|
||||
readonly memoryUsage: number;
|
||||
readonly networkRxTotal: number;
|
||||
readonly networkTxTotal: number;
|
||||
readonly diskReadTotal: number;
|
||||
readonly diskWriteTotal: number;
|
||||
}
|
||||
|
||||
export interface ContainerMount {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly destination: string;
|
||||
readonly rw: boolean;
|
||||
}
|
||||
|
||||
export interface MountStat {
|
||||
readonly destination: string;
|
||||
readonly total: number;
|
||||
readonly free: number;
|
||||
readonly used: number;
|
||||
readonly available: boolean;
|
||||
readonly lastChecked: string;
|
||||
}
|
||||
|
||||
export type ContainerJson = {
|
||||
@@ -22,6 +40,8 @@ export type ContainerJson = {
|
||||
readonly memoryLimit: number;
|
||||
readonly labels: Record<string, string>;
|
||||
readonly stats: ContainerStat[];
|
||||
readonly mounts?: ContainerMount[];
|
||||
readonly mountStats?: Record<string, MountStat>;
|
||||
readonly health?: ContainerHealth;
|
||||
readonly group?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
// Theme-aware ANSI palette. Values are CSS variables defined per DaisyUI theme in main.css,
|
||||
// so log colors stay legible on both light and dark backgrounds. The `colors` map merges over
|
||||
// the library's 256-color defaults, so indices 16-255 keep their standard values.
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
colors: {
|
||||
0: "var(--ansi-black)",
|
||||
1: "var(--ansi-red)",
|
||||
2: "var(--ansi-green)",
|
||||
3: "var(--ansi-yellow)",
|
||||
4: "var(--ansi-blue)",
|
||||
5: "var(--ansi-magenta)",
|
||||
6: "var(--ansi-cyan)",
|
||||
7: "var(--ansi-white)",
|
||||
8: "var(--ansi-bright-black)",
|
||||
9: "var(--ansi-bright-red)",
|
||||
10: "var(--ansi-bright-green)",
|
||||
11: "var(--ansi-bright-yellow)",
|
||||
12: "var(--ansi-bright-blue)",
|
||||
13: "var(--ansi-bright-magenta)",
|
||||
14: "var(--ansi-bright-cyan)",
|
||||
15: "var(--ansi-bright-white)",
|
||||
},
|
||||
});
|
||||
|
||||
export const colorize = (value: string): string => ansiConvertor.toHtml(value);
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { formatDuration, toRelativeTime } from "./date";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
// Assertions stay tolerant so they pass whether Intl.DurationFormat is present
|
||||
// (narrow style emits seconds) or the manual fallback is used (drops seconds).
|
||||
test("hours and minutes", () => {
|
||||
const result = formatDuration(3661, "en");
|
||||
expect(result).toContain("1h");
|
||||
expect(result).toContain("1m");
|
||||
});
|
||||
|
||||
test("minutes and seconds", () => {
|
||||
const result = formatDuration(90, "en");
|
||||
expect(result).toContain("1m");
|
||||
expect(result).toContain("30s");
|
||||
});
|
||||
|
||||
test("seconds only", () => {
|
||||
expect(formatDuration(45, "en")).toContain("45s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRelativeTime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("past days", () => {
|
||||
expect(toRelativeTime(new Date("2026-01-13T00:00:00Z"), "en")).toBe("2 days ago");
|
||||
});
|
||||
|
||||
test("past hour", () => {
|
||||
expect(toRelativeTime(new Date("2026-01-14T23:00:00Z"), "en")).toBe("1 hour ago");
|
||||
});
|
||||
|
||||
test("future", () => {
|
||||
expect(toRelativeTime(new Date("2026-01-17T00:00:00Z"), "en")).toBe("in 2 days");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Parses the JSON payload of an SSE `MessageEvent`. EventSource listeners
|
||||
* receive a `MessageEvent` whose `data` is an untyped string, so the parsed
|
||||
* shape is provided via the type parameter at each call site.
|
||||
*/
|
||||
export function parseEventData<T>(event: Event): T {
|
||||
return JSON.parse((event as MessageEvent).data) as T;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatBytes, stripVersion, hashCode } from "./format";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
test("zero bytes", () => {
|
||||
expect(formatBytes(0)).toBe("0 Bytes");
|
||||
expect(formatBytes(0, { short: true })).toBe("0B");
|
||||
});
|
||||
|
||||
test("bytes under 1KB", () => {
|
||||
expect(formatBytes(512)).toBe("512 Bytes");
|
||||
expect(formatBytes(512, { short: true })).toBe("512B");
|
||||
});
|
||||
|
||||
test("scales to KB/MB/GB", () => {
|
||||
expect(formatBytes(1024)).toBe("1 KB");
|
||||
expect(formatBytes(1024, { short: true })).toBe("1K");
|
||||
expect(formatBytes(1024 * 1024)).toBe("1 MB");
|
||||
expect(formatBytes(1024 * 1024, { short: true })).toBe("1M");
|
||||
expect(formatBytes(1024 * 1024 * 1024)).toBe("1 GB");
|
||||
});
|
||||
|
||||
test("honors decimals option", () => {
|
||||
expect(formatBytes(1500)).toBe("1.46 KB");
|
||||
expect(formatBytes(1500, { decimals: 0 })).toBe("1 KB");
|
||||
expect(formatBytes(1500, { decimals: 1 })).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
test("negative decimals clamp to zero", () => {
|
||||
expect(formatBytes(1500, { decimals: -3 })).toBe("1 KB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripVersion", () => {
|
||||
test("removes tag", () => {
|
||||
expect(stripVersion("nginx:1.25")).toBe("nginx");
|
||||
});
|
||||
|
||||
test("leaves untagged image unchanged", () => {
|
||||
expect(stripVersion("nginx")).toBe("nginx");
|
||||
});
|
||||
|
||||
test("splits on first colon", () => {
|
||||
expect(stripVersion("registry:5000/img:tag")).toBe("registry");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashCode", () => {
|
||||
test("empty string is zero", () => {
|
||||
expect(hashCode("")).toBe(0);
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashCode("dozzle")).toBe(hashCode("dozzle"));
|
||||
});
|
||||
|
||||
test("differs for different input", () => {
|
||||
expect(hashCode("a")).not.toBe(hashCode("b"));
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@ export { formatDuration, toRelativeTime } from "./date";
|
||||
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
||||
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
||||
export { formatBytes, stripVersion, hashCode } from "./format";
|
||||
export { colorize } from "./ansi";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { flattenJSON, getDeep, isObject, arrayEquals } from "./object";
|
||||
|
||||
describe("flattenJSON", () => {
|
||||
test("flattens nested objects to dot keys", () => {
|
||||
expect(flattenJSON({ a: { b: 1 } })).toEqual({ "a.b": 1 });
|
||||
expect(flattenJSON({ a: { b: { c: 2 } } })).toEqual({ "a.b.c": 2 });
|
||||
});
|
||||
|
||||
test("keeps top-level primitives", () => {
|
||||
expect(flattenJSON({ a: 1, b: "x" })).toEqual({ a: 1, b: "x" });
|
||||
});
|
||||
|
||||
test("keeps arrays as values (not recursed)", () => {
|
||||
expect(flattenJSON({ a: [1, 2] })).toEqual({ a: [1, 2] });
|
||||
});
|
||||
|
||||
test("empty object", () => {
|
||||
expect(flattenJSON({})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeep", () => {
|
||||
test("reads a nested path", () => {
|
||||
expect(getDeep({ a: { b: { c: 5 } } }, ["a", "b", "c"])).toBe(5);
|
||||
});
|
||||
|
||||
test("returns undefined for a missing path without throwing", () => {
|
||||
expect(getDeep({ a: {} }, ["a", "b", "c"])).toBeUndefined();
|
||||
expect(getDeep({}, ["x"])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isObject", () => {
|
||||
test("true for plain objects", () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
});
|
||||
|
||||
test("false for arrays, null, and primitives", () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
expect(isObject(null)).toBe(false);
|
||||
expect(isObject(5)).toBe(false);
|
||||
expect(isObject("s")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrayEquals", () => {
|
||||
test("equal arrays", () => {
|
||||
expect(arrayEquals(["a", "b"], ["a", "b"])).toBe(true);
|
||||
expect(arrayEquals([], [])).toBe(true);
|
||||
});
|
||||
|
||||
test("different length or order", () => {
|
||||
expect(arrayEquals(["a"], ["a", "b"])).toBe(false);
|
||||
expect(arrayEquals(["a", "b"], ["b", "a"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
||||
+52
-4
@@ -1,3 +1,10 @@
|
||||
x-dozzle-healthcheck: &dozzle-healthcheck
|
||||
test: ["CMD", "/dozzle", "healthcheck"]
|
||||
interval: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
start_interval: 5s
|
||||
|
||||
services:
|
||||
custom_base:
|
||||
container_name: custom_base
|
||||
@@ -10,6 +17,7 @@ services:
|
||||
- DOZZLE_HOSTNAME=custom name
|
||||
ports:
|
||||
- 8080:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
simple-auth:
|
||||
@@ -20,6 +28,7 @@ services:
|
||||
environment:
|
||||
- DOZZLE_AUTH_PROVIDER=simple
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
dozzle:
|
||||
@@ -33,8 +42,33 @@ services:
|
||||
- DOZZLE_LEVEL=debug
|
||||
ports:
|
||||
- 7070:8080
|
||||
# Deliberately no healthcheck: this container is the subject of the visual
|
||||
# snapshots, and a healthcheck makes a health badge appear in the sidebar
|
||||
# (HostMenu.vue), which shifts the reference images.
|
||||
build:
|
||||
context: .
|
||||
# Dedicated instance + noisy container so log streaming can be asserted without
|
||||
# perturbing the `name=dozzle` filter the other instances rely on.
|
||||
logs-viewer:
|
||||
container_name: logs-viewer
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DOZZLE_FILTER=name=logspam
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
- DOZZLE_HOSTNAME=logs
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
- logspam
|
||||
logspam:
|
||||
container_name: logspam
|
||||
image: alpine:3
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
|
||||
remote:
|
||||
container_name: remote
|
||||
environment:
|
||||
@@ -43,6 +77,7 @@ services:
|
||||
- DOZZLE_NO_ANALYTICS=1
|
||||
ports:
|
||||
- 5050:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
@@ -56,6 +91,7 @@ services:
|
||||
- DOZZLE_LEVEL=debug
|
||||
ports:
|
||||
- 8082:8080
|
||||
healthcheck: *dozzle-healthcheck
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
@@ -85,6 +121,9 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- CONTAINERS=1
|
||||
# Dozzle calls /info on connect; without it the host never initializes and
|
||||
# lists zero containers.
|
||||
- INFO=1
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
||||
interval: 5s
|
||||
@@ -96,7 +135,7 @@ services:
|
||||
|
||||
playwright:
|
||||
container_name: playwright
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-jammy
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
@@ -105,6 +144,15 @@ services:
|
||||
- PWTEST_SKIP_TEST_OUTPUT=1
|
||||
- CI=1
|
||||
depends_on:
|
||||
- dozzle
|
||||
- custom_base
|
||||
- remote
|
||||
dozzle:
|
||||
condition: service_started
|
||||
custom_base:
|
||||
condition: service_healthy
|
||||
remote:
|
||||
condition: service_healthy
|
||||
simple-auth:
|
||||
condition: service_healthy
|
||||
dozzle-with-agent:
|
||||
condition: service_healthy
|
||||
logs-viewer:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
declare const data: { stars: number; pulls: number };
|
||||
declare const data: {
|
||||
stars: number;
|
||||
pulls: number;
|
||||
contributors: number;
|
||||
release: { version: string; date: string } | null;
|
||||
};
|
||||
export { data };
|
||||
|
||||
// The contributors endpoint has no total count, so ask for one per page and read
|
||||
// the last page number out of the Link header.
|
||||
async function fetchContributorCount() {
|
||||
const res = await fetch("https://api.github.com/repos/amir20/dozzle/contributors?per_page=1&anon=false");
|
||||
const match = res.headers.get("link")?.match(/[?&]page=(\d+)>; rel="last"/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease() {
|
||||
const res = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
|
||||
const json = await res.json();
|
||||
if (!json.tag_name) return null;
|
||||
return { version: json.tag_name, date: json.published_at };
|
||||
}
|
||||
|
||||
export default {
|
||||
async load() {
|
||||
const urls = [
|
||||
@@ -8,13 +28,18 @@ export default {
|
||||
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
|
||||
];
|
||||
|
||||
const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json())));
|
||||
const [repo, hub, contributors, release] = await Promise.all([
|
||||
...urls.map((url) => fetch(url).then((res) => res.json())),
|
||||
// Never fail the build over a rate-limited or flaky API call.
|
||||
fetchContributorCount().catch(() => 0),
|
||||
fetchLatestRelease().catch(() => null),
|
||||
]);
|
||||
|
||||
const data = {
|
||||
stars: responses[0].stargazers_count,
|
||||
pulls: responses[1].pull_count,
|
||||
return {
|
||||
stars: repo.stargazers_count,
|
||||
pulls: hub.pull_count,
|
||||
contributors,
|
||||
release,
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,14 @@ export default defineConfig({
|
||||
cleanUrls: true,
|
||||
|
||||
head: [
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:site_name", content: "Dozzle" }],
|
||||
["meta", { property: "og:url", content: "https://dozzle.dev/" }],
|
||||
["meta", { property: "og:image", content: "https://dozzle.dev/og-image.png" }],
|
||||
["meta", { property: "og:image:width", content: "1200" }],
|
||||
["meta", { property: "og:image:height", content: "630" }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:image", content: "https://dozzle.dev/og-image.png" }],
|
||||
[
|
||||
"script",
|
||||
{
|
||||
@@ -82,6 +90,7 @@ export default defineConfig({
|
||||
{ text: "Authentication", link: "/guide/authentication" },
|
||||
{ text: "Actions", link: "/guide/actions" },
|
||||
{ text: "Shell Access", link: "/guide/shell" },
|
||||
{ text: "MCP Integration", link: "/guide/mcp" },
|
||||
{ text: "Agent Mode", link: "/guide/agent" },
|
||||
{ text: "Reverse Proxy & Base Path", link: "/guide/changing-base" },
|
||||
{ text: "Container Names", link: "/guide/container-names" },
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="banner fixed top-0 right-0 left-0 z-(--vp-z-index-layout-top) flex items-center overflow-hidden bg-[oklch(74%_0.16_232.661)] p-4 font-bold text-gray-800 dark:bg-[oklch(60%_0.126_221.723)] dark:text-white"
|
||||
>
|
||||
<div class="mx-auto flex items-center gap-2 lg:gap-4">
|
||||
<span class="animate-bounce">🎉</span>Dozzle v10 is here! Alerts, webhooks, and Dozzle Cloud are now available.
|
||||
<a href="/guide/alerts-and-webhooks" class="btn btn-sm btn-primary text-white!"> Learn more </a>
|
||||
</div>
|
||||
<button class="btn btn-circle btn-ghost btn-xs ml-auto" @click="dismiss">
|
||||
<svg class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512">
|
||||
<polygon
|
||||
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
const dismiss = () => {
|
||||
localStorage.setItem("banner-dismissed", (Date.now() + 8.64e7 * 1).toString());
|
||||
document.documentElement.classList.add("banner-dismissed");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (new Date(+localStorage.getItem("banner-dismissed")) > Date.now()) {
|
||||
document.documentElement.classList.add("banner-dismissed");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.banner-dismissed {
|
||||
--vp-layout-top-height: 0px !important;
|
||||
}
|
||||
@media (min-width: 375px) {
|
||||
html {
|
||||
--vp-layout-top-height: 100px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
--vp-layout-top-height: 63px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.banner-dismissed .banner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
import { withBase } from "vitepress";
|
||||
import InstallCommand from "./InstallCommand.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto mt-20 max-w-[1152px] px-6">
|
||||
<div
|
||||
class="flex flex-col items-center rounded-2xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) px-6 py-16"
|
||||
>
|
||||
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
|
||||
Start watching your containers
|
||||
</h2>
|
||||
<p class="mt-3! mb-0! max-w-xl text-center text-(--vp-c-text-2)">
|
||||
One command, no agent to install, no account to create.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 w-full">
|
||||
<InstallCommand :hint="false" dense />
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<a class="cta-button cta-brand" :href="withBase('/guide/getting-started')">Get Started</a>
|
||||
<a class="cta-button cta-alt" href="https://github.com/amir20/dozzle">
|
||||
<Icon icon="simple-icons:github" class="size-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-8! mb-0! text-sm text-(--vp-c-text-2)">MIT licensed and free forever.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Mirror VitePress's own button tokens so these match the hero buttons in both themes. */
|
||||
.cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
text-decoration: none !important;
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
border-color 0.25s,
|
||||
color 0.25s;
|
||||
}
|
||||
|
||||
.cta-brand {
|
||||
border-color: var(--vp-button-brand-border);
|
||||
background-color: var(--vp-button-brand-bg);
|
||||
color: var(--vp-button-brand-text);
|
||||
}
|
||||
|
||||
.cta-brand:hover {
|
||||
border-color: var(--vp-button-brand-hover-border);
|
||||
background-color: var(--vp-button-brand-hover-bg);
|
||||
color: var(--vp-button-brand-hover-text);
|
||||
}
|
||||
|
||||
.cta-alt {
|
||||
border-color: var(--vp-button-alt-border);
|
||||
background-color: var(--vp-button-alt-bg);
|
||||
color: var(--vp-button-alt-text);
|
||||
}
|
||||
|
||||
.cta-alt:hover {
|
||||
border-color: var(--vp-button-alt-hover-border);
|
||||
background-color: var(--vp-button-alt-hover-bg);
|
||||
color: var(--vp-button-alt-hover-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { data } from "../../activity.data";
|
||||
|
||||
const cues = [
|
||||
{ icon: "mdi:scale-balance", label: "MIT licensed" },
|
||||
{ icon: "mdi:package-variant-closed", label: "Single binary, no database" },
|
||||
{ icon: "mdi:server-security", label: "Self-hosted, your logs stay on your network" },
|
||||
];
|
||||
|
||||
const released = computed(() => {
|
||||
if (!data.release) return null;
|
||||
const days = Math.floor((Date.now() - new Date(data.release.date).getTime()) / 86_400_000);
|
||||
if (days <= 0) return "released today";
|
||||
if (days === 1) return "released yesterday";
|
||||
if (days < 30) return `released ${days} days ago`;
|
||||
const months = Math.round(days / 30);
|
||||
return `released ${months} month${months === 1 ? "" : "s"} ago`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex flex-col gap-2 text-sm text-(--vp-c-text-2)">
|
||||
<ul class="m-0! flex list-none flex-wrap gap-x-5 gap-y-2 p-0!">
|
||||
<li v-for="cue in cues" :key="cue.label" class="flex items-center gap-1.5">
|
||||
<Icon :icon="cue.icon" class="size-4 opacity-70" />
|
||||
{{ cue.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
v-if="data.release"
|
||||
class="flex w-fit items-center gap-1.5 text-(--vp-c-text-3) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
:href="`https://github.com/amir20/dozzle/releases/tag/${data.release.version}`"
|
||||
>
|
||||
<span class="relative flex size-2">
|
||||
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60"></span>
|
||||
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
|
||||
</span>
|
||||
{{ data.release.version }} {{ released }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMutationObserver } from "@vueuse/core";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||
import { useData } from "vitepress";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const isDark = ref(false);
|
||||
onMounted(() => {
|
||||
isDark.value = document.documentElement.classList.contains("dark");
|
||||
useMutationObserver(
|
||||
document.documentElement,
|
||||
(mutations) => {
|
||||
isDark.value = document.documentElement.classList.contains("dark");
|
||||
},
|
||||
{
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
},
|
||||
);
|
||||
});
|
||||
import darkPoster from "../media/poster-dark.webp";
|
||||
import lightPoster from "../media/poster-light.webp";
|
||||
import darkVideo from "../media/dozzle-dark.mp4";
|
||||
import lightVideo from "../media/dozzle-light.mp4";
|
||||
|
||||
const { isDark } = useData();
|
||||
const reducedMotion = usePreferredReducedMotion();
|
||||
|
||||
// The posters are rendered on the server and toggled with CSS so the first paint
|
||||
// is already the right theme. The video only mounts on the client, which keeps
|
||||
// markup identical between SSR and hydration and avoids downloading both files.
|
||||
const mounted = ref(false);
|
||||
onMounted(() => (mounted.value = true));
|
||||
|
||||
const playVideo = computed(() => mounted.value && reducedMotion.value !== "reduce");
|
||||
const video = computed(() => (isDark.value ? darkVideo : lightVideo));
|
||||
const poster = computed(() => (isDark.value ? darkPoster : lightPoster));
|
||||
|
||||
const alt = "The Dozzle interface streaming container logs in real time";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] text-[red] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
class="relative overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
>
|
||||
<video muted loop autoplay playsinline poster="../media/poster-dark.png" v-if="isDark">
|
||||
<source src="../media/dozzle-dark.mp4" type="video/mp4" />
|
||||
<img src="../media/poster-dark.png" alt="" />
|
||||
</video>
|
||||
<video muted loop autoplay playsinline poster="../media/poster-light.png" v-else>
|
||||
<source src="../media/dozzle-light.mp4" type="video/mp4" />
|
||||
<img src="../media/poster-light.png" alt="" />
|
||||
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
|
||||
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
|
||||
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
|
||||
<video
|
||||
v-if="playVideo"
|
||||
:key="video"
|
||||
class="absolute inset-0 size-full"
|
||||
muted
|
||||
loop
|
||||
autoplay
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-hidden="true"
|
||||
:poster="poster"
|
||||
>
|
||||
<source :src="video" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
const { hint = true, dense = false } = defineProps<{
|
||||
hint?: boolean;
|
||||
dense?: boolean;
|
||||
}>();
|
||||
|
||||
const command =
|
||||
"docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest";
|
||||
|
||||
const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-[1152px] flex-col items-center gap-3 px-6" :class="dense ? 'mb-0' : 'mb-14'">
|
||||
<div
|
||||
class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4"
|
||||
>
|
||||
<span class="shrink-0 font-mono text-xs leading-6 text-(--vp-c-text-3) select-none md:text-sm">$</span>
|
||||
<code
|
||||
class="min-w-0 flex-1 bg-transparent! p-0! font-mono text-xs leading-6 break-words whitespace-pre-wrap text-(--vp-c-text-1) md:text-sm"
|
||||
>{{ command }}</code
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 cursor-pointer rounded-md border border-solid border-(--vp-c-divider) bg-(--vp-c-bg) px-3 py-1.5 text-xs font-medium text-(--vp-c-text-2) transition-colors hover:border-(--vp-c-brand) hover:text-(--vp-c-brand)"
|
||||
:aria-label="copied ? 'Command copied' : 'Copy command to clipboard'"
|
||||
@click="copy()"
|
||||
>
|
||||
{{ copied ? "Copied" : "Copy" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="hint" class="m-0! text-sm text-(--vp-c-text-2)">
|
||||
Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the
|
||||
<a :href="withBase('/guide/getting-started')">install guide</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { withBase } from "vitepress";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-16 mb-4 flex items-center justify-center px-6">
|
||||
<a
|
||||
class="flex items-center gap-2 text-sm text-(--vp-c-text-2) no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
href="https://www.docker.com/blog/docker-sponsored-open-source-program/"
|
||||
>
|
||||
<img :src="withBase('/icons/docker-icon.svg')" width="20" height="20" alt="" />
|
||||
Open source and part of Docker's sponsored OSS program
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user