mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 23:11:57 +00:00
Compare commits
241 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f28474d46 | |||
| a25c379771 | |||
| 7aa449d77c | |||
| e1e2959856 | |||
| 8e39e35127 | |||
| 9f2eb5ec47 | |||
| 8358b98111 | |||
| af3aefe830 | |||
| ebc54a4481 | |||
| 8cf7ccd5ee | |||
| a9395e9f73 | |||
| b9df31356f | |||
| d2abd0e279 | |||
| 8a6ce3b21d | |||
| 239850c52d | |||
| 6b84105e79 | |||
| c159db5f0b | |||
| a18edf03ec | |||
| 18acf6722b | |||
| 5e0a4c43e6 | |||
| 48f04a0a7d | |||
| ae1753bcee | |||
| 3ae53dd719 | |||
| 3e0ed70362 | |||
| c6c64fdd86 | |||
| 616dd91483 | |||
| f952961eea | |||
| 3ca08f781b | |||
| 03011eea97 | |||
| 3783125b6a | |||
| fe6549e5f5 | |||
| cedb384dc4 | |||
| 163452a398 | |||
| f348b57721 | |||
| df5a60479c | |||
| 9c665d42a9 | |||
| 49f625a1db | |||
| af38e3a0c0 | |||
| 104749063d | |||
| d7b896e3d8 | |||
| 5693bcc4e4 | |||
| de70cbe389 | |||
| 73099aa67a | |||
| 9266f89776 | |||
| f88d7a1066 | |||
| f3e61606bd | |||
| 95794779d6 | |||
| e3f4566f6f | |||
| b86831d2ba | |||
| 4917984385 | |||
| c3b1c76151 | |||
| 7897a1555e | |||
| 523ac9f35e | |||
| 6e65b33161 | |||
| be2a9e0047 | |||
| fad2ea7763 | |||
| 4e7b6eea7b | |||
| a0704d5c7b | |||
| 63fa7c9e04 | |||
| 237e916390 | |||
| 6a16e35eb1 | |||
| 15b6abf843 | |||
| 540ace4e84 | |||
| c0a38587e2 | |||
| 2b1d9e889e | |||
| 5cd63429a9 | |||
| 820831b03d | |||
| 8cde0e7e48 | |||
| bd4651fb0b | |||
| 8d810b7a0b | |||
| c193e601f3 | |||
| 78ff45fdaf | |||
| ac42ce868a | |||
| 84ac9e51ac | |||
| 3062410b42 | |||
| beddc79892 | |||
| f7557ccbb5 | |||
| 6391853f3a | |||
| 619a844feb | |||
| e989caed33 | |||
| ad2b124a9e | |||
| 19c01e0fb4 | |||
| b38117bfae | |||
| 134f812336 | |||
| 55ab21b1bf | |||
| 853f46deb4 | |||
| ff0f855cf6 | |||
| 38b8511042 | |||
| 3c68345938 | |||
| acaec204b6 | |||
| 9b9c04498c | |||
| f13e169d10 | |||
| 2b23684dbd | |||
| 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 | |||
| eca7008e1f | |||
| 1893f48493 | |||
| 8dac197f60 | |||
| 883f5464e3 | |||
| 023ff1242d | |||
| f3c17ee493 | |||
| 79184bbfd2 | |||
| fa7479e1ec | |||
| 543f96198f | |||
| 581bab3a43 | |||
| 3dc5e3afab | |||
| b3e5f2d2b0 | |||
| 4de47308a6 | |||
| a9c773a15a | |||
| c5c301ffb2 | |||
| 980f52be6f | |||
| e1bfb88dad | |||
| 84c4d1144f | |||
| 2478cd16d6 | |||
| 0dc3720795 | |||
| 57fa1ba77a | |||
| 551ecce726 | |||
| 74fd80229c | |||
| e0e061f47c | |||
| e3d3c789df | |||
| 7d3164dede | |||
| 4466001c78 | |||
| e6aa6adce6 | |||
| 116006dbbf | |||
| 6ce3750062 | |||
| ef59688748 | |||
| 8d020d618c | |||
| 9288249aca | |||
| 321ae607f6 | |||
| 416db140c8 | |||
| 6ba57afe0b | |||
| 59994ea431 | |||
| cebe484da5 | |||
| 837b994c19 | |||
| c48dcc5c4b |
@@ -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@v2
|
||||
- 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
|
||||
|
||||
+8
-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 ./
|
||||
@@ -19,6 +21,9 @@ COPY assets ./assets
|
||||
COPY locales ./locales
|
||||
COPY public ./public
|
||||
|
||||
ARG CLOUD_URL
|
||||
ENV CLOUD_URL=$CLOUD_URL
|
||||
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
@@ -21,13 +21,17 @@ 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"
|
||||
|
||||
.PHONY: docker
|
||||
docker: generate
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle:local .
|
||||
@docker build --build-arg TAG=local --build-arg CLOUD_URL=$(CLOUD_URL) -t amir20/dozzle:local .
|
||||
|
||||
.PHONY: generate
|
||||
generate: shared_key.pem shared_cert.pem
|
||||
@@ -49,11 +53,6 @@ shared_cert.pem: shared_key.pem
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
|
||||
@rm shared_request.csr
|
||||
|
||||
.PHONY: push
|
||||
push: docker
|
||||
@docker tag amir20/dozzle:local amir20/dozzle:local-test
|
||||
@docker push amir20/dozzle:local-test
|
||||
|
||||
.PHONY: run
|
||||
run: docker
|
||||
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
|
||||
|
||||
@@ -41,7 +41,7 @@ Dozzle is a small container (7 MB compressed). Pull the latest release with:
|
||||
|
||||
The simplest way to use Dozzle is to run the Docker container. Mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
||||
|
||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
|
||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest
|
||||
|
||||
Dozzle will be available at [http://localhost:8080/](http://localhost:8080/).
|
||||
|
||||
@@ -53,8 +53,11 @@ Here is a Docker Compose example:
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dozzle_data:/data
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
dozzle_data:
|
||||
|
||||
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts), or common [questions](https://dozzle.dev/guide/faq), see the documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
|
||||
|
||||
@@ -78,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.
|
||||
@@ -140,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
+36
-3
@@ -1,15 +1,19 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
/* oxlint-disable */
|
||||
/* oxfmt-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
@@ -17,6 +21,7 @@ declare global {
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const colorize: typeof import('./utils/index').colorize
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
@@ -28,6 +33,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
|
||||
@@ -54,6 +60,7 @@ declare global {
|
||||
const drawerContext: typeof import('./composable/drawer').drawerContext
|
||||
const eagerComputed: typeof import('@vueuse/core').eagerComputed
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const escapeHtml: typeof import('./utils/index').escapeHtml
|
||||
const extendRef: typeof import('@vueuse/core').extendRef
|
||||
const flattenJSON: typeof import('./utils/index').flattenJSON
|
||||
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
|
||||
@@ -64,10 +71,13 @@ 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
|
||||
const hourStyle: typeof import('./stores/settings').hourStyle
|
||||
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
|
||||
const inject: typeof import('vue').inject
|
||||
@@ -114,6 +124,7 @@ declare global {
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
@@ -158,6 +169,7 @@ declare global {
|
||||
const stripVersion: typeof import('./utils/index').stripVersion
|
||||
const syncRef: typeof import('@vueuse/core').syncRef
|
||||
const syncRefs: typeof import('@vueuse/core').syncRefs
|
||||
const syntaxHighlightJson: typeof import('./utils/index').syntaxHighlightJson
|
||||
const templateRef: typeof import('@vueuse/core').templateRef
|
||||
const throttledRef: typeof import('@vueuse/core').throttledRef
|
||||
const throttledWatch: typeof import('@vueuse/core').throttledWatch
|
||||
@@ -168,6 +180,7 @@ declare global {
|
||||
const toRelativeTime: typeof import('./utils/index').toRelativeTime
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const tryFormatJson: typeof import('./utils/index').tryFormatJson
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
|
||||
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
|
||||
@@ -206,7 +219,9 @@ declare global {
|
||||
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||
const useCloned: typeof import('@vueuse/core').useCloned
|
||||
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
|
||||
@@ -238,6 +253,7 @@ declare global {
|
||||
const useElementBounding: typeof import('@vueuse/core').useElementBounding
|
||||
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
|
||||
const useElementHover: typeof import('@vueuse/core').useElementHover
|
||||
const useElementOverflow: typeof import('@vueuse/core').useElementOverflow
|
||||
const useElementSize: typeof import('@vueuse/core').useElementSize
|
||||
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
|
||||
const useEventBus: typeof import('@vueuse/core').useEventBus
|
||||
@@ -254,11 +270,13 @@ declare global {
|
||||
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
|
||||
const useFps: typeof import('@vueuse/core').useFps
|
||||
const useFullscreen: typeof import('@vueuse/core').useFullscreen
|
||||
const useFuzzySearch: typeof import('./composable/fuzzySearch').useFuzzySearch
|
||||
const useGamepad: typeof import('@vueuse/core').useGamepad
|
||||
const useGeolocation: typeof import('@vueuse/core').useGeolocation
|
||||
const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream
|
||||
const useHead: typeof import('@vueuse/head').useHead
|
||||
const useHistoricalContainerLog: typeof import('./composable/historicalLogs').useHistoricalContainerLog
|
||||
const useHostGroupStream: typeof import('./composable/eventStreams').useHostGroupStream
|
||||
const useHostStream: typeof import('./composable/eventStreams').useHostStream
|
||||
const useHosts: typeof import('./stores/hosts').useHosts
|
||||
const useI18n: typeof import('vue-i18n').useI18n
|
||||
@@ -406,10 +424,16 @@ declare global {
|
||||
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
|
||||
import('./composable/alertForm')
|
||||
// @ts-ignore
|
||||
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'
|
||||
@@ -424,7 +448,7 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
@@ -447,6 +471,7 @@ declare module 'vue' {
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
||||
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
|
||||
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
@@ -458,6 +483,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']>
|
||||
@@ -494,8 +520,10 @@ declare module 'vue' {
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -544,6 +572,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
@@ -567,7 +596,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']>
|
||||
@@ -636,7 +664,9 @@ declare module 'vue' {
|
||||
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
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']>
|
||||
@@ -668,6 +698,7 @@ declare module 'vue' {
|
||||
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
|
||||
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
|
||||
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
|
||||
readonly useElementOverflow: UnwrapRef<typeof import('@vueuse/core')['useElementOverflow']>
|
||||
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
|
||||
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
|
||||
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
|
||||
@@ -684,11 +715,13 @@ declare module 'vue' {
|
||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
readonly useFuzzySearch: UnwrapRef<typeof import('./composable/fuzzySearch')['useFuzzySearch']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
|
||||
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
|
||||
readonly useHistoricalContainerLog: UnwrapRef<typeof import('./composable/historicalLogs')['useHistoricalContainerLog']>
|
||||
readonly useHostGroupStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostGroupStream']>
|
||||
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
|
||||
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
|
||||
@@ -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
+23
-3
@@ -37,6 +37,7 @@ declare module 'vue' {
|
||||
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
|
||||
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
|
||||
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
|
||||
CloudSearchInline: typeof import('./components/CloudSearchInline.vue')['default']
|
||||
CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
|
||||
@@ -62,13 +63,18 @@ declare module 'vue' {
|
||||
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
|
||||
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
|
||||
HostCard: typeof import('./components/HostCard.vue')['default']
|
||||
HostGroupLog: typeof import('./components/HostViewer/HostGroupLog.vue')['default']
|
||||
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
|
||||
HostList: typeof import('./components/HostList.vue')['default']
|
||||
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
|
||||
HostMenu: typeof import('./components/HostMenu.vue')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
|
||||
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']
|
||||
JsonValue: typeof import('./components/common/JsonValue.vue')['default']
|
||||
K8sMenu: typeof import('./components/K8sMenu.vue')['default']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
|
||||
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
|
||||
@@ -92,11 +98,15 @@ declare module 'vue' {
|
||||
'MaterialSymbols:person': typeof import('~icons/material-symbols/person')['default']
|
||||
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
|
||||
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
|
||||
'MaterialSymbolsLight:expandAll': typeof import('~icons/material-symbols-light/expand-all')['default']
|
||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
|
||||
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
|
||||
'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']
|
||||
@@ -111,14 +121,20 @@ declare module 'vue' {
|
||||
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
|
||||
'Mdi:close': typeof import('~icons/mdi/close')['default']
|
||||
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
|
||||
'Mdi:cloudCheckOutline': typeof import('~icons/mdi/cloud-check-outline')['default']
|
||||
'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default']
|
||||
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
|
||||
'Mdi:cloudSearchOutline': typeof import('~icons/mdi/cloud-search-outline')['default']
|
||||
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
|
||||
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
|
||||
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
|
||||
'Mdi: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']
|
||||
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
|
||||
'Mdi:heart': typeof import('~icons/mdi/heart')['default']
|
||||
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
|
||||
'Mdi:key': typeof import('~icons/mdi/key')['default']
|
||||
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
@@ -148,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']
|
||||
@@ -168,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']
|
||||
@@ -186,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(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="search"
|
||||
class="bg-base-200 border-base-content/15 hover:border-primary/50 hover:bg-base-200/80 flex h-9 w-full items-center gap-2 rounded-md border px-3 text-left transition-colors"
|
||||
@click="openSearch"
|
||||
>
|
||||
<mdi:magnify class="size-4 shrink-0" :class="cloudReady ? 'text-primary' : 'text-base-content/60'" />
|
||||
<!-- Show the active query when we're on the cloud search page so the
|
||||
topbar reflects what the user is looking at. -->
|
||||
<span v-if="activeQuery" class="text-base-content truncate font-mono text-sm">{{ activeQuery }}</span>
|
||||
<span v-else class="text-base-content/60 truncate text-sm">
|
||||
<template v-if="cloudReady">{{ $t("cloud-search.hero-title-cloud") }}</template>
|
||||
<template v-else>{{ $t("cloud-search.hero-title-plain") }}</template>
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
<kbd class="kbd kbd-xs">⌘</kbd>
|
||||
<kbd class="kbd kbd-xs">K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useFuzzySearch } from "@/composable/fuzzySearch";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
|
||||
const { openSearch } = useFuzzySearch();
|
||||
const { cloudConfig } = useCloudConfig();
|
||||
const cloudReady = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
|
||||
|
||||
const route = useRoute();
|
||||
const activeQuery = computed(() =>
|
||||
route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "",
|
||||
);
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-base-content/15 bg-base-200/40 divide-base-content/10 divide-y rounded-lg border">
|
||||
<!-- Not linked -->
|
||||
<template v-if="!cloudConfig">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex items-start gap-4 p-4">
|
||||
<mdi:cloud class="text-base-content/40 mt-0.5 text-4xl" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-base-content/70 text-sm">{{ $t("cloud.description") }}</p>
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<div v-if="cloudStatusError" class="space-y-3">
|
||||
<div v-if="cloudStatusError" class="space-y-3 p-4">
|
||||
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
@@ -47,35 +47,57 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 py-2">
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 p-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<div v-else-if="cloudStatus" class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-success">{{ $t("cloud.connected") }}</span>
|
||||
<span class="badge badge-primary capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
<template v-else-if="cloudStatus">
|
||||
<div class="flex flex-wrap items-center gap-2 p-4">
|
||||
<span class="status-pill status-pill-success">
|
||||
<span class="size-1.5 rounded-full bg-current"></span>
|
||||
{{ $t("cloud.connected") }}
|
||||
</span>
|
||||
<span class="status-pill status-pill-primary">{{ cloudStatus.plan.name }}</span>
|
||||
<span class="text-base-content/50 text-sm">{{ cloudStatus.user.email }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
|
||||
<span>
|
||||
{{ cloudStatus.usage.events_used.toLocaleString() }} /
|
||||
{{ cloudStatus.usage.events_limit.toLocaleString() }}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-base-content/60 text-sm font-medium">{{ $t("cloud.usage") }}</span>
|
||||
<span class="font-mono text-sm">
|
||||
<span class="font-semibold">{{ cloudStatus.usage.events_used.toLocaleString() }}</span>
|
||||
<span class="text-base-content/40"> / {{ cloudStatus.usage.events_limit.toLocaleString() }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full max-w-xs"
|
||||
class="progress w-full"
|
||||
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
<div class="text-base-content/40 flex justify-between font-mono text-xs">
|
||||
<span v-if="cloudStatus.usage.period">{{ cloudStatus.usage.period }}</span>
|
||||
<span v-else></span>
|
||||
<span>{{ usagePercent.toFixed(2) }}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<label class="flex min-h-13 cursor-pointer items-center justify-between gap-4 p-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium">{{ $t("cloud.stream-logs") }}</span>
|
||||
<span class="text-base-content/60 text-xs">{{ $t("cloud.stream-logs-help") }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm shrink-0"
|
||||
:checked="streamLogs"
|
||||
:disabled="isSavingStreamLogs"
|
||||
@change="onStreamLogsChange(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2 p-4">
|
||||
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
|
||||
{{ $t("cloud.dashboard") }}
|
||||
</a>
|
||||
@@ -83,7 +105,7 @@
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Unlink confirmation modal -->
|
||||
@@ -118,13 +140,40 @@ const {
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
fetchCloudConfig,
|
||||
initialLoad,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
} = useCloudConfig();
|
||||
const isUnlinking = ref(false);
|
||||
const unlinkModal = ref<HTMLDialogElement | null>(null);
|
||||
|
||||
const streamLogs = ref(true);
|
||||
const isSavingStreamLogs = ref(false);
|
||||
watchEffect(() => {
|
||||
if (cloudConfig.value) streamLogs.value = cloudConfig.value.streamLogs;
|
||||
});
|
||||
|
||||
async function onStreamLogsChange(value: boolean | undefined) {
|
||||
if (!cloudConfig.value || value === undefined) return;
|
||||
isSavingStreamLogs.value = true;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ streamLogs: value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
streamLogs.value = !value;
|
||||
return;
|
||||
}
|
||||
cloudConfig.value.streamLogs = value;
|
||||
} catch {
|
||||
streamLogs.value = !value;
|
||||
} finally {
|
||||
isSavingStreamLogs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
@@ -150,7 +199,7 @@ async function doUnlink() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCloudConfig();
|
||||
await initialLoad;
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
|
||||
@@ -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,13 +87,16 @@
|
||||
<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>
|
||||
<div v-if="container.customGroup" class="text-base-content/50 truncate text-xs">
|
||||
{{ container.customGroup }}
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
|
||||
<td v-if="isVisible('state')">{{ container.state }}</td>
|
||||
@@ -142,6 +145,7 @@ const fields: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
mobileLabel?: string;
|
||||
sortFunc: (a: Container, b: Container) => number;
|
||||
mobileVisible: boolean;
|
||||
customClass?: string;
|
||||
@@ -149,7 +153,9 @@ const fields: Record<
|
||||
> = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
mobileLabel: "label.name",
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.name.localeCompare(b.name) || (a.customGroup ?? "").localeCompare(b.customGroup ?? "")) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
host: {
|
||||
@@ -167,21 +173,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,16 @@
|
||||
<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.hostLabel !== container.hostLabel" class="text-base-content/50 text-xs">
|
||||
{{ other.hostLabel }}
|
||||
</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
@@ -42,8 +48,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 +71,21 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { showToast } = useToast();
|
||||
|
||||
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
|
||||
|
||||
async function copyImage() {
|
||||
if (!isSupported.value) return;
|
||||
await copy(imageTag.value);
|
||||
if (copied.value) {
|
||||
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.name),
|
||||
set: (value) => {
|
||||
@@ -67,7 +101,7 @@ const { containers: allContainers } = storeToRefs(store);
|
||||
|
||||
const otherContainers = computed(() =>
|
||||
allContainers.value
|
||||
.filter((c) => c.name === container.name && c.id !== container.id)
|
||||
.filter((c) => c.name === container.name && c.id !== container.id && c.customGroup === container.customGroup)
|
||||
.sort((a, b) => +b.created - +a.created),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -109,7 +110,7 @@ describe("<FuzzySearchModal />", () => {
|
||||
await wrapper.find("input").setValue("foo");
|
||||
expect(wrapper.findAll("li").length).toBe(1);
|
||||
expect(wrapper.find("ul [data-name]").html()).toMatchInlineSnapshot(
|
||||
`"<span data-v-dc2e8c61="" data-name=""><mark>foo</mark> bar</span>"`,
|
||||
`"<span data-v-dc2e8c61="" class="text-base-content" data-name=""><mark>foo</mark> bar</span>"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,70 +1,188 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-open w-full shadow-md">
|
||||
<div class="input input-xl input-primary flex w-full items-center">
|
||||
<mdi:magnify class="flex size-8" />
|
||||
<!-- Single bordered card containing the input, results, and footer in one
|
||||
frame to match the design mock. No daisyUI input/dropdown chrome. -->
|
||||
<div class="bg-base-200 border-base-content/15 w-full overflow-hidden rounded-xl border shadow-2xl">
|
||||
<!-- Input row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3.5">
|
||||
<mdi:magnify class="text-base-content/60 size-5 shrink-0" />
|
||||
<input
|
||||
tabindex="0"
|
||||
class="input-ghost flex-1 px-1"
|
||||
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="selected(data[selectedIndex].item)"
|
||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
||||
@keydown.enter.exact="onEnter"
|
||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||
@keydown.alt.enter.exact.prevent="onPin"
|
||||
v-model="query"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
:placeholder="placeholderCopy"
|
||||
/>
|
||||
<form method="dialog" class="flex">
|
||||
<button v-if="isMobile">
|
||||
<mdi:close />
|
||||
<button v-if="isMobile" class="text-base-content/50 hover:text-base-content">
|
||||
<mdi:close class="size-5" />
|
||||
</button>
|
||||
<button v-else class="swap hover:swap-active outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
<button v-else>
|
||||
<kbd class="kbd kbd-xs">esc</kbd>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-content bg-base-100 relative! mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent px-2"
|
||||
tabindex="0"
|
||||
v-if="results.length"
|
||||
>
|
||||
<ul class="menu w-auto">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
|
||||
@click.prevent="selected(result.item)"
|
||||
:class="{ 'menu-focus': index === selectedIndex }"
|
||||
>
|
||||
<div :class="{ 'text-primary': result.item.state === 'running' }">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack />
|
||||
</template>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="font-light">{{ result.item.host }}</span> /
|
||||
</template>
|
||||
<span data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
|
||||
<RelativeTime :date="result.item.created" class="text-xs font-light" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="hover:text-secondary"
|
||||
<!-- Body: results + log search CTA. Only renders when there is something
|
||||
to show — keeps the empty modal compact. -->
|
||||
<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)"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" />
|
||||
<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"
|
||||
class="border-base-content/10 border-t"
|
||||
:class="{ 'cursor-pointer': cloudSearch.available.value, 'opacity-70': !cloudSearch.available.value }"
|
||||
@click="cloudSearch.available.value && runLogSearch()"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3"
|
||||
:class="cloudSearch.available.value ? 'bg-primary/[0.07] hover:bg-primary/10' : ''"
|
||||
>
|
||||
<mdi:cloud-search-outline
|
||||
class="size-5 shrink-0"
|
||||
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/40'"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
class="truncate text-sm font-semibold"
|
||||
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/60'"
|
||||
>
|
||||
<i18n-t keypath="cloud-search.search-logs-for">
|
||||
<template #query>
|
||||
<span class="font-mono">{{ query }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-base-content/50 mt-0.5 flex items-center gap-1 text-xs">
|
||||
<template v-if="cloudSearch.available.value">
|
||||
<mdi:flash class="text-primary size-3" />
|
||||
{{ $t("cloud-search.across-containers") }}
|
||||
</template>
|
||||
<template v-else-if="cloudConfig?.linked && !cloudConfig.streamLogs">
|
||||
<mdi:cloud-off-outline class="size-3" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.enable-streaming-to-search") }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<mdi:cloud-off-outline class="size-3" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.connect-to-enable") }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<kbd class="kbd kbd-xs">⇧</kbd>
|
||||
<kbd class="kbd kbd-xs">↵</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: kbd hints + cloud status. Always present while the modal is
|
||||
open so users know log search is available before they type. -->
|
||||
<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="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">
|
||||
<kbd class="kbd kbd-xs">⇧</kbd><kbd class="kbd kbd-xs">↵</kbd>
|
||||
<span class="ml-0.5">{{ $t("cloud-search.search-logs-shortcut") }}</span>
|
||||
</span>
|
||||
|
||||
<span v-if="cloudSearch.available.value" class="ml-auto flex items-center gap-1.5">
|
||||
<mdi:cloud-check-outline class="text-primary size-3.5" />
|
||||
{{ $t("cloud-search.cloud-connected") }}
|
||||
</span>
|
||||
<span v-else-if="cloudConfig?.linked" class="ml-auto flex items-center gap-1.5">
|
||||
<mdi:cloud-off-outline class="size-3.5" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.enable-streaming-to-search") }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
<span v-else class="ml-auto flex items-center gap-1.5">
|
||||
<mdi:cloud-off-outline class="size-3.5" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.connect-to-enable") }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -73,15 +191,30 @@
|
||||
import { ContainerState } from "@/types/Container";
|
||||
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();
|
||||
|
||||
const query = ref("");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Prefill with the current /cloud/search query so the user can refine
|
||||
// without retyping. Empty everywhere else. Null-safe for unit tests
|
||||
// that mount the component without a router context.
|
||||
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);
|
||||
|
||||
const router = useRouter();
|
||||
// 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);
|
||||
@@ -89,12 +222,27 @@ const { visibleContainers } = storeToRefs(containerStore);
|
||||
const swarmStore = useSwarmStore();
|
||||
const { stacks, services } = storeToRefs(swarmStore);
|
||||
|
||||
const { cloudConfig } = useCloudConfig();
|
||||
// Mounted only so the "Search logs for X" CTA can read `available`. We
|
||||
// don't render the hits inside the popup. The composable's debounced
|
||||
// watch short-circuits on empty query, so opening the modal alone does
|
||||
// not fire a request.
|
||||
const cloudSearch = useCloudLogSearch(query);
|
||||
|
||||
const logSearchVisible = computed(() => query.value.trim().length > 0);
|
||||
|
||||
const { t } = useI18n();
|
||||
const placeholderCopy = computed(() =>
|
||||
cloudSearch.available.value ? t("cloud-search.modal-placeholder-cloud") : t("cloud-search.modal-placeholder-plain"),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const dialog = input.value?.closest("dialog");
|
||||
if (dialog) {
|
||||
const animations = dialog.getAnimations();
|
||||
await Promise.all(animations.map((animation) => animation.finished));
|
||||
input.value?.focus();
|
||||
if (initialQuery) input.value?.select();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,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) {
|
||||
@@ -172,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) {
|
||||
@@ -193,6 +367,43 @@ function selected(item: Item) {
|
||||
close();
|
||||
}
|
||||
|
||||
async function runCommand(command: Command) {
|
||||
close();
|
||||
await command.perform();
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
// 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();
|
||||
if (!q) return;
|
||||
router.push({ path: "/cloud/search", query: { q } });
|
||||
close();
|
||||
}
|
||||
|
||||
function addColumn(container: { id: string }) {
|
||||
pinnedStore.pinContainer(container);
|
||||
close();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+164
-25
@@ -36,9 +36,10 @@
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("label.show-all-containers") }}
|
||||
</a>
|
||||
<a class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:collapse-all class="w-4" />
|
||||
{{ $t("label.collapse-all") }}
|
||||
<a v-if="hasCollapsible" class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:expand-all class="w-4" v-if="allCollapsed" />
|
||||
<material-symbols-light:collapse-all class="w-4" v-else />
|
||||
{{ allCollapsed ? $t("label.expand-all") : $t("label.collapse-all") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -49,13 +50,71 @@
|
||||
<SlideTransition :slide-right="!!sessionHost">
|
||||
<template #left>
|
||||
<ul class="menu p-0">
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<a @click.prevent="setHost(host.id)" :class="{ 'text-base-content/50 pointer-events-none': !host.available }">
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
<template v-if="!hasHostGroups">
|
||||
<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" />
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<template v-else v-for="[groupName, groupHosts] in groupedHostEntries" :key="groupName || '__ungrouped__'">
|
||||
<li v-if="groupName" class="host-group">
|
||||
<details :open="!collapsedHostGroups.has(groupName)" @toggle="updateCollapsedHostGroups($event, groupName)">
|
||||
<summary class="host-group-summary">
|
||||
<span class="truncate">{{ groupName }}</span>
|
||||
<router-link
|
||||
:to="{ name: '/host-group/[name]', params: { name: groupName } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
@click.stop
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
<button
|
||||
v-if="!collapsedHostGroups.has(groupName)"
|
||||
type="button"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
:title="$t('label.collapse-group')"
|
||||
@click.stop.prevent="collapseHostGroup(groupName)"
|
||||
>
|
||||
<material-symbols-light:collapse-all />
|
||||
</button>
|
||||
</summary>
|
||||
<ul>
|
||||
<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" />
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<template v-else>
|
||||
<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" />
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -91,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">
|
||||
@@ -130,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();
|
||||
@@ -146,7 +202,30 @@ const { hosts } = useHosts();
|
||||
|
||||
const setHost = (host: string | null) => (sessionHost.value = host);
|
||||
|
||||
const hasHostGroups = computed(() => Object.values(hosts.value).some((h) => h.group));
|
||||
|
||||
const groupedHostEntries = computed(() => {
|
||||
const groups: Record<string, (typeof hosts.value)[string][]> = {};
|
||||
const ungrouped: (typeof hosts.value)[string][] = [];
|
||||
|
||||
for (const host of Object.values(hosts.value)) {
|
||||
if (host.group) {
|
||||
groups[host.group] ||= [];
|
||||
groups[host.group].push(host);
|
||||
} else {
|
||||
ungrouped.push(host);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)) as [string, typeof ungrouped][];
|
||||
if (ungrouped.length > 0) {
|
||||
entries.push(["", ungrouped]);
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
const collapsedGroups = useProfileStorage("collapsedGroups", new Set<string>());
|
||||
const collapsedHostGroups = useProfileStorage("collapsedHostGroups", new Set<string>());
|
||||
const updateCollapsedGroups = (event: Event, label: string) => {
|
||||
const details = event.target as HTMLDetailsElement;
|
||||
if (details.open) {
|
||||
@@ -159,10 +238,51 @@ const updateCollapsedGroups = (event: Event, label: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateCollapsedHostGroups = (event: Event, groupName: string) => {
|
||||
const details = event.target as HTMLDetailsElement;
|
||||
if (details.open) {
|
||||
collapsedHostGroups.value.delete(groupName);
|
||||
} else {
|
||||
collapsedHostGroups.value.add(groupName);
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const collapseHostGroup = (groupName: string) => {
|
||||
collapsedHostGroups.value.add(groupName);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const hasCollapsible = computed(
|
||||
() => menuItems.value.length > 0 || groupedHostEntries.value.some(([groupName]) => groupName),
|
||||
);
|
||||
|
||||
const allCollapsed = computed(() => {
|
||||
const containerGroups = menuItems.value;
|
||||
const hostGroups = groupedHostEntries.value.filter(([groupName]) => groupName);
|
||||
if (containerGroups.length === 0 && hostGroups.length === 0) return false;
|
||||
return (
|
||||
containerGroups.every(({ label }) => collapsedGroups.value.has(label)) &&
|
||||
hostGroups.every(([groupName]) => collapsedHostGroups.value.has(groupName))
|
||||
);
|
||||
});
|
||||
|
||||
const collapseAll = () => {
|
||||
menuItems.value.forEach(({ label }) => {
|
||||
collapsedGroups.value.add(label);
|
||||
});
|
||||
if (allCollapsed.value) {
|
||||
menuItems.value.forEach(({ label }) => collapsedGroups.value.delete(label));
|
||||
groupedHostEntries.value.forEach(([groupName]) => {
|
||||
if (groupName) collapsedHostGroups.value.delete(groupName);
|
||||
});
|
||||
} else {
|
||||
menuItems.value.forEach(({ label }) => collapsedGroups.value.add(label));
|
||||
groupedHostEntries.value.forEach(([groupName]) => {
|
||||
if (groupName) collapsedHostGroups.value.add(groupName);
|
||||
});
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
@@ -232,14 +352,18 @@ const menuItems = computed(() => {
|
||||
|
||||
const route = useRoute("/container/[id]");
|
||||
|
||||
watchEffect(() => {
|
||||
if (route.name === "/container/[id]") {
|
||||
const container = containerStore.findContainerById(route.params.id);
|
||||
if (container) {
|
||||
setHost(container.host);
|
||||
watch(
|
||||
[() => route.name, () => route.params.id],
|
||||
([name, id]) => {
|
||||
if (name === "/container/[id]") {
|
||||
const container = containerStore.findContainerById(id as string);
|
||||
if (container) {
|
||||
setHost(container.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const toggleShowAllContainers = () => (showAllContainers.value = !showAllContainers.value);
|
||||
</script>
|
||||
@@ -248,6 +372,21 @@ const toggleShowAllContainers = () => (showAllContainers.value = !showAllContain
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
|
||||
.host-group-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, auto) max-content max-content max-content;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 0.5rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0.25rem;
|
||||
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
|
||||
}
|
||||
|
||||
.host-group-summary::after {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
li.exited {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div v-if="groupHosts.length === 0" class="hero min-h-[50vh]">
|
||||
<div class="hero-content text-center">
|
||||
<p class="text-base-content/70 text-lg">{{ $t("error.host-group-not-found", { name }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableView :scrollable="scrollable" v-else>
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<ph:computer-tower />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ name }}</div>
|
||||
</div>
|
||||
<Tag class="font-mono max-md:hidden" size="small">
|
||||
{{ $t("label.host-count", groupHosts.length) }}
|
||||
</Tag>
|
||||
<Tag class="font-mono max-md:hidden" size="small">
|
||||
{{ $t("label.container", containers.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useHostGroupStream"
|
||||
:entity="groupRef"
|
||||
:visible-keys="visibleKeys"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { useHostGroupStream } from "@/composable/eventStreams";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { name, scrollable = false } = defineProps<{
|
||||
name: string;
|
||||
scrollable?: boolean;
|
||||
}>();
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const store = useContainerStore();
|
||||
const { containersByHost } = storeToRefs(store);
|
||||
|
||||
const groupHosts = computed(() => Object.values(hosts.value).filter((h) => h.group === name));
|
||||
const groupRef = computed(() => ({ name }));
|
||||
const visibleKeys = new Map<string[], boolean>();
|
||||
|
||||
const containers = computed(() =>
|
||||
groupHosts.value.flatMap((h) => containersByHost.value?.[h.id]?.filter((c) => c.state === "running") ?? []),
|
||||
);
|
||||
|
||||
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
provideLoggingContext(containers, { showContainerName: true, showHostname: true });
|
||||
</script>
|
||||
@@ -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";
|
||||
@@ -41,10 +46,11 @@ defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
if (historical.value && route.query.logId) {
|
||||
if (historical.value && typeof route.query.logId === "string") {
|
||||
const targetId = route.query.logId;
|
||||
watchOnce(messages, async () => {
|
||||
await nextTick();
|
||||
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
document.getElementById(targetId)?.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
|
||||
<JsonFormatted :value="entry.rawMessage" class="text-sm" />
|
||||
</div>
|
||||
</section>
|
||||
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
|
||||
@@ -142,28 +142,6 @@ const toggleAllFields = computed({
|
||||
},
|
||||
});
|
||||
|
||||
function syntaxHighlight(json: string) {
|
||||
json = JSON.stringify(JSON.parse(json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")), null, 2);
|
||||
return json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
|
||||
function (match: string) {
|
||||
var cls = "json-number";
|
||||
if (match.startsWith('"')) {
|
||||
if (match.endsWith(":")) {
|
||||
cls = "json-key";
|
||||
} else {
|
||||
cls = "json-string";
|
||||
}
|
||||
} else if (/true|false/.test(match)) {
|
||||
cls = "json-boolean";
|
||||
} else if (/null/.test(match)) {
|
||||
cls = "json-null";
|
||||
}
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
useSortable(list, fields);
|
||||
</script>
|
||||
<style scoped>
|
||||
@@ -179,22 +157,4 @@ useSortable(list, fields);
|
||||
Menlo,
|
||||
monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
& :deep(.json-key) {
|
||||
@apply text-blue;
|
||||
}
|
||||
& :deep(.json-string) {
|
||||
@apply text-green;
|
||||
}
|
||||
& :deep(.json-number) {
|
||||
@apply text-orange;
|
||||
}
|
||||
& :deep(.json-boolean) {
|
||||
@apply text-purple;
|
||||
}
|
||||
& :deep(.json-null) {
|
||||
@apply text-red;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,12 +14,7 @@
|
||||
>
|
||||
{{ container.name }}
|
||||
</RandomColorTag>
|
||||
<LogDate
|
||||
v-if="showTimestamp"
|
||||
:date="logEntry.date"
|
||||
class="shrink-0 select-none"
|
||||
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
|
||||
/>
|
||||
<LogDate v-if="showTimestamp" :date="logEntry.date" class="shrink-0 select-none" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -37,6 +32,4 @@ const { hosts } = useHosts();
|
||||
|
||||
const container = currentContainer(toRef(() => logEntry.containerID));
|
||||
const host = computed(() => hosts.value[container.value.host]);
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:id="item.id.toString()"
|
||||
:data-time="item.date.getTime()"
|
||||
class="group/entry"
|
||||
:class="{ 'log-permalink-target': permalinkLogId === item.id.toString() }"
|
||||
>
|
||||
<component :is="item.getComponent()" :log-entry="item" />
|
||||
</li>
|
||||
@@ -24,6 +25,9 @@ const { messages } = defineProps<{
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const route = useRoute();
|
||||
const permalinkLogId = computed(() => (typeof route.query.logId === "string" ? route.query.logId : ""));
|
||||
|
||||
const list = ref<HTMLElement[]>([]);
|
||||
|
||||
let previousDate = new Date();
|
||||
@@ -71,6 +75,11 @@ ul {
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
|
||||
&.log-permalink-target {
|
||||
@apply bg-secondary/15 border-secondary -ml-1 border-l-4 pl-3;
|
||||
animation: log-permalink-pulse 1.4s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@@ -113,4 +122,14 @@ ul {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes log-permalink-pulse {
|
||||
0% {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
100% {
|
||||
/* Settle to the resting bg-secondary/15 declared on the .li above. */
|
||||
background-color: color-mix(in oklab, var(--color-secondary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 px-4 py-4 md:px-8">
|
||||
<section>
|
||||
<Links>
|
||||
<section class="flex items-center gap-4">
|
||||
<CloudSearchInline class="hidden max-w-sm flex-1 md:flex" />
|
||||
<Links class="ml-auto">
|
||||
<template #more-items>
|
||||
<Tag>{{ config.version }}</Tag>
|
||||
<Tag class="font-mono">{{ config.version }}</Tag>
|
||||
</template>
|
||||
</Links>
|
||||
</section>
|
||||
|
||||
@@ -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,25 +1,14 @@
|
||||
<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>
|
||||
|
||||
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
|
||||
</h1>
|
||||
|
||||
<button
|
||||
class="input input-sm hover:border-primary mt-2 inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
|
||||
@click="$emit('search')"
|
||||
:title="$t('tooltip.search')"
|
||||
data-testid="search"
|
||||
>
|
||||
<mdi:magnify />
|
||||
{{ $t("placeholder.search") }}
|
||||
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
|
||||
</button>
|
||||
|
||||
<SideMenu class="flex-1" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -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,37 @@
|
||||
<template>
|
||||
<span class="json" :class="{ 'json-block': block }">
|
||||
<JsonValue :value="parsed" :indent="block ? 0 : -1" :highlight="highlight" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import JsonValue from "./JsonValue.vue";
|
||||
|
||||
const {
|
||||
value,
|
||||
highlight,
|
||||
block = true,
|
||||
} = defineProps<{
|
||||
value: unknown;
|
||||
highlight?: string;
|
||||
block?: boolean;
|
||||
}>();
|
||||
|
||||
const parsed = computed(() => {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
.json-block {
|
||||
@apply block font-mono break-all whitespace-pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -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,31 @@
|
||||
<template>
|
||||
<template v-if="!highlight">{{ text }}</template>
|
||||
<template v-else>
|
||||
<template v-for="(part, i) in parts" :key="i">
|
||||
<mark v-if="part.match" class="bg-warning text-warning-content rounded px-0.5">{{ part.text }}</mark>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { text, highlight } = defineProps<{
|
||||
text: string;
|
||||
highlight?: string;
|
||||
}>();
|
||||
|
||||
const parts = computed(() => {
|
||||
if (!highlight) return [{ text, match: false }];
|
||||
const pattern = highlight.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
||||
const re = new RegExp(pattern, "gi");
|
||||
const result: { text: string; match: boolean }[] = [];
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(re)) {
|
||||
if (m.index! > last) result.push({ text: text.slice(last, m.index), match: false });
|
||||
result.push({ text: m[0], match: true });
|
||||
last = m.index! + m[0].length;
|
||||
}
|
||||
if (last < text.length) result.push({ text: text.slice(last), match: false });
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<template v-if="value === null">
|
||||
<span class="json-null">null</span>
|
||||
</template>
|
||||
<template v-else-if="typeof value === 'boolean'">
|
||||
<span class="json-boolean">{{ String(value) }}</span>
|
||||
</template>
|
||||
<template v-else-if="typeof value === 'number'">
|
||||
<span class="json-number">{{ value }}</span>
|
||||
</template>
|
||||
<template v-else-if="typeof value === 'string'">
|
||||
<span class="json-string">"<JsonText :text="value" :highlight="highlight" />"</span>
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<template v-if="value.length === 0">
|
||||
<span>[]</span>
|
||||
</template>
|
||||
<template v-else-if="indent < 0">
|
||||
<span>[</span>
|
||||
<template v-for="(item, i) in value" :key="i">
|
||||
<JsonValue :value="item" :indent="indent" :highlight="highlight" />
|
||||
<span v-if="i < value.length - 1">, </span>
|
||||
</template>
|
||||
<span>]</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>[</span>
|
||||
<template v-for="(item, i) in value" :key="i">
|
||||
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
|
||||
<JsonValue :value="item" :indent="indent + 1" :highlight="highlight" />
|
||||
<span v-if="i < value.length - 1">,</span>
|
||||
</template>
|
||||
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
|
||||
<span>]</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="typeof value === 'object'">
|
||||
<template v-if="entries.length === 0">
|
||||
<span>{}</span>
|
||||
</template>
|
||||
<template v-else-if="indent < 0">
|
||||
<span>{</span>
|
||||
<template v-for="([k, v], i) in entries" :key="k">
|
||||
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
|
||||
<JsonValue :value="v" :indent="indent" :highlight="highlight" />
|
||||
<span v-if="i < entries.length - 1">, </span>
|
||||
</template>
|
||||
<span>}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{</span>
|
||||
<template v-for="([k, v], i) in entries" :key="k">
|
||||
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
|
||||
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
|
||||
<JsonValue :value="v" :indent="indent + 1" :highlight="highlight" />
|
||||
<span v-if="i < entries.length - 1">,</span>
|
||||
</template>
|
||||
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
|
||||
<span>}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ String(value) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import JsonText from "./JsonText.vue";
|
||||
|
||||
const {
|
||||
value,
|
||||
indent = 0,
|
||||
highlight,
|
||||
} = defineProps<{
|
||||
value: unknown;
|
||||
indent?: number;
|
||||
highlight?: string;
|
||||
}>();
|
||||
|
||||
const entries = computed(() =>
|
||||
value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value as Record<string, unknown>) : [],
|
||||
);
|
||||
|
||||
function pad(level: number): string {
|
||||
return " ".repeat(Math.max(level, 0));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
.json-key {
|
||||
@apply text-blue;
|
||||
}
|
||||
.json-string {
|
||||
@apply text-green;
|
||||
}
|
||||
.json-number {
|
||||
@apply text-orange;
|
||||
}
|
||||
.json-boolean {
|
||||
@apply text-purple;
|
||||
}
|
||||
.json-null {
|
||||
@apply text-red;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<ph:command v-if="isMac" class="size-4" />
|
||||
<ph:control-bold v-else class="size-4" />
|
||||
</template>
|
||||
<kbd class="uppercase">{{ char }}</kbd>
|
||||
<kbd class="kbd kbd-xs ml-0.5 uppercase">{{ char }}</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,10 @@ async function fetchCloudConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded once at module import (i.e. app boot). Every consumer reads the
|
||||
// shared `cloudConfig` ref — no per-component fetch.
|
||||
const initialLoad = fetchCloudConfig();
|
||||
|
||||
async function fetchCloudStatus() {
|
||||
if (!cloudConfig.value?.linked) return;
|
||||
isLoadingCloudStatus.value = true;
|
||||
@@ -49,6 +53,7 @@ export function useCloudConfig() {
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
initialLoad,
|
||||
fetchCloudConfig,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
|
||||
export interface CloudLogHit {
|
||||
ts: number;
|
||||
hostId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
message: string;
|
||||
stream: string;
|
||||
level: string;
|
||||
// Dozzle's deterministic FNV-32a id for the raw log line — used to deep-link
|
||||
// to the exact line in the local log viewer. Optional: pre-indexing logs
|
||||
// (or older Dozzle clients) won't have it.
|
||||
logId?: number;
|
||||
}
|
||||
|
||||
interface CloudLogSearchResponse {
|
||||
hits: CloudLogHit[];
|
||||
hasMore: boolean;
|
||||
// Cursor for the next older page. Pass back as `before=` in the URL.
|
||||
// Omitted when there's nothing more to load.
|
||||
nextBefore?: number;
|
||||
}
|
||||
|
||||
const debounceMs = 250;
|
||||
|
||||
/**
|
||||
* useCloudLogSearch performs Cloud-side log search via the Dozzle backend's
|
||||
* /api/cloud/search/logs endpoint. Identity is derived server-side from the
|
||||
* authenticated gRPC connection; this composable passes only the query.
|
||||
*
|
||||
* Behavior:
|
||||
* - debounced 250ms; whitespace-only short-circuits to []
|
||||
* - aborts any in-flight request on each new keystroke (AbortController)
|
||||
* - `available` is computed: cloud linked AND streamLogs enabled
|
||||
* - when `available` is false, results stay [] regardless of query
|
||||
*
|
||||
* Status mapping:
|
||||
* 200 -> hits populated (may be empty)
|
||||
* 204 -> streaming disabled server-side (defense-in-depth)
|
||||
* 503 -> cloud not configured
|
||||
* 504 -> timeout (500ms upstream)
|
||||
* any other 4xx/5xx -> error set, results cleared
|
||||
*/
|
||||
export function useCloudLogSearch(query: Ref<string>) {
|
||||
const { cloudConfig } = useCloudConfig();
|
||||
|
||||
const results = ref<CloudLogHit[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
const hasMore = ref(false);
|
||||
// Cursor (timestamp_ns) of the last hit on the current page; 0 = at the
|
||||
// newest page. Cleared on every new query.
|
||||
const nextBefore = ref<number>(0);
|
||||
|
||||
const available = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
|
||||
|
||||
// Two parallel fetch lifecycles — keystroke search (cancels on next
|
||||
// keystroke / unmount) and pagination loadMore (cancels on unmount or
|
||||
// when a new query lands and supersedes pagination state). Tracking
|
||||
// them separately avoids the keystroke aborter cancelling an in-flight
|
||||
// pagination request and vice versa.
|
||||
let abortController: AbortController | null = null;
|
||||
let loadMoreAborter: AbortController | null = null;
|
||||
|
||||
function clearResults() {
|
||||
results.value = [];
|
||||
error.value = null;
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
hasMore.value = false;
|
||||
nextBefore.value = 0;
|
||||
}
|
||||
|
||||
async function fetchPage(q: string, before: number, signal: AbortSignal): Promise<CloudLogSearchResponse | null> {
|
||||
let url = withBase(`/api/cloud/search/logs?q=${encodeURIComponent(q)}&limit=20`);
|
||||
if (before > 0) url += `&before=${before}`;
|
||||
const res = await fetch(url, { signal });
|
||||
if (res.status === 204) return { hits: [], hasMore: false };
|
||||
if (!res.ok) throw new Error(`cloud search failed: ${res.status}`);
|
||||
return (await res.json()) as CloudLogSearchResponse;
|
||||
}
|
||||
|
||||
async function runSearch(q: string) {
|
||||
if (abortController) abortController.abort();
|
||||
// A fresh query supersedes any in-flight pagination — that page is
|
||||
// for the previous query and would be appended onto the wrong result
|
||||
// set if it landed late.
|
||||
loadMoreAborter?.abort();
|
||||
abortController = new AbortController();
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
nextBefore.value = 0;
|
||||
|
||||
try {
|
||||
const body = await fetchPage(q, 0, abortController.signal);
|
||||
if (!body) return;
|
||||
results.value = body.hits ?? [];
|
||||
hasMore.value = !!body.hasMore;
|
||||
nextBefore.value = body.nextBefore ?? 0;
|
||||
} catch (e) {
|
||||
if ((e as DOMException)?.name !== "AbortError") {
|
||||
error.value = e as Error;
|
||||
results.value = [];
|
||||
hasMore.value = false;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// loadMore appends the next older page. Safe to call repeatedly — guarded
|
||||
// by hasMore + a separate loading flag so the input-debounced search and
|
||||
// the user-triggered pagination don't trip each other.
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value || nextBefore.value <= 0) return;
|
||||
const q = query.value.trim();
|
||||
if (!q) return;
|
||||
loadingMore.value = true;
|
||||
loadMoreAborter?.abort();
|
||||
loadMoreAborter = new AbortController();
|
||||
try {
|
||||
const body = await fetchPage(q, nextBefore.value, loadMoreAborter.signal);
|
||||
if (!body) return;
|
||||
results.value = [...results.value, ...(body.hits ?? [])];
|
||||
hasMore.value = !!body.hasMore;
|
||||
nextBefore.value = body.nextBefore ?? 0;
|
||||
} catch (e) {
|
||||
if ((e as DOMException)?.name !== "AbortError") error.value = e as Error;
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
[query, available],
|
||||
([q, isAvailable]) => {
|
||||
const trimmed = q.trim();
|
||||
if (!isAvailable || trimmed === "") {
|
||||
clearResults();
|
||||
return;
|
||||
}
|
||||
runSearch(trimmed);
|
||||
},
|
||||
{ debounce: debounceMs, immediate: true },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
abortController?.abort();
|
||||
loadMoreAborter?.abort();
|
||||
});
|
||||
|
||||
return { results, loading, loadingMore, error, available, hasMore, loadMore };
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -28,6 +29,10 @@ export function useHostStream(host: Ref<Host>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/hosts/${host.value.id}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useHostGroupStream(group: Ref<{ name: string }>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/host-groups/${encodeURIComponent(group.value.name)}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
const labels = computed(() => `com.docker.stack.namespace:${stack.value.name}`);
|
||||
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
|
||||
@@ -52,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>) {
|
||||
@@ -69,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;
|
||||
@@ -77,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);
|
||||
}
|
||||
@@ -151,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,
|
||||
@@ -171,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)];
|
||||
@@ -207,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Shared open state for the global Cmd+K fuzzy-search modal.
|
||||
// Lives outside the layout so any surface (home page hero, mobile menu,
|
||||
// sidebar trigger) can open the same modal without prop-drilling.
|
||||
const open = ref(false);
|
||||
|
||||
export function useFuzzySearch() {
|
||||
return {
|
||||
open,
|
||||
openSearch: () => {
|
||||
open.value = true;
|
||||
},
|
||||
closeSearch: () => {
|
||||
open.value = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
|
||||
<Splitpanes @resized="onResized($event)">
|
||||
<Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
|
||||
<SidePanel @search="showFuzzySearch" />
|
||||
<SidePanel />
|
||||
</Pane>
|
||||
<Pane min-size="10" :size="100 - menuWidth">
|
||||
<Splitpanes>
|
||||
@@ -34,9 +34,9 @@
|
||||
<mdi:chevron-left class="swap-off" />
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="open = false">
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="open = false" v-if="open" />
|
||||
<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 overflow-visible! bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="closeSearch" v-if="open" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
@@ -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";
|
||||
@@ -63,8 +62,10 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
const drawer = useTemplateRef<InstanceType<typeof SideDrawer>>("drawer") as Ref<InstanceType<typeof SideDrawer>>;
|
||||
const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer);
|
||||
|
||||
import { useFuzzySearch } from "@/composable/fuzzySearch";
|
||||
|
||||
const modal = ref<HTMLDialogElement>();
|
||||
const open = ref(false);
|
||||
const { open, openSearch: showFuzzySearch, closeSearch } = useFuzzySearch();
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const forceMenuHidden = ref(searchParams.has("hideMenu"));
|
||||
|
||||
@@ -83,10 +84,6 @@ onKeyStroke("k", (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
function showFuzzySearch() {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function onResized({ panes }: { panes: { size: number }[] }) {
|
||||
if (panes.length == 2) {
|
||||
menuWidth.value = Math.min(panes[0].size, 50);
|
||||
@@ -103,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 |
+85
-1
@@ -1,5 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
@import "splitpanes/dist/splitpanes.css" layer(base);
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/500.css";
|
||||
@import "@fontsource/jetbrains-mono/600.css";
|
||||
@import "@fontsource/jetbrains-mono/700.css";
|
||||
@plugin "daisyui";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@@ -9,6 +13,8 @@
|
||||
--color-purple: oklch(51.49% 0.215 321.03);
|
||||
--color-blue: oklch(65% 0.171 249.5);
|
||||
--color-orange: oklch(85% 0.186 48.13);
|
||||
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@utility pt-safe {
|
||||
@@ -51,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;
|
||||
@@ -91,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;
|
||||
@@ -114,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 {
|
||||
@@ -137,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);
|
||||
@@ -144,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;
|
||||
}
|
||||
@@ -190,5 +255,24 @@ body {
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
@apply inline-flex items-center gap-1.5 rounded border px-2 py-0.5 font-mono text-xs font-medium tracking-wider uppercase;
|
||||
}
|
||||
.status-pill-neutral {
|
||||
@apply bg-base-100 border-base-content/15 text-base-content/70;
|
||||
}
|
||||
.status-pill-success {
|
||||
@apply text-success border-success/30 bg-success/10;
|
||||
}
|
||||
.status-pill-primary {
|
||||
@apply text-primary border-primary/30 bg-primary/10;
|
||||
}
|
||||
.status-pill-warning {
|
||||
@apply text-warning border-warning/30 bg-warning/10;
|
||||
}
|
||||
.status-pill-error {
|
||||
@apply text-error border-error/30 bg-error/10;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<PageWithLinks>
|
||||
<section>
|
||||
<!-- Header -->
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">{{ $t("cloud-search.results-page-title") }}</h2>
|
||||
<span v-if="committedQuery" class="text-base-content/70 font-mono text-sm">"{{ committedQuery }}"</span>
|
||||
<span v-if="cloudSearch.available.value" class="status-pill status-pill-primary ml-auto">
|
||||
<mdi:flash class="size-3" /> {{ $t("cloud-search.hero-pill-indexed") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Status line -->
|
||||
<div class="text-base-content/70 mb-3 flex h-5 items-center gap-2 text-xs">
|
||||
<template v-if="cloudSearch.loading.value">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span>{{ $t("cloud-search.searching") }}</span>
|
||||
</template>
|
||||
<template v-else-if="cloudSearch.error.value">
|
||||
<mdi:alert-circle-outline class="text-error size-3.5" />
|
||||
<span>{{ $t("cloud-search.search-failed") }}</span>
|
||||
</template>
|
||||
<template v-else-if="committedQuery && hits.length === 0">
|
||||
<span>{{ $t("cloud-search.no-results") }}</span>
|
||||
</template>
|
||||
<template v-else-if="!committedQuery">
|
||||
<span>{{ $t("cloud-search.search-empty-prompt") }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-mono">{{ $t("cloud-search.hits-count", { n: hits.length }) }}</span>
|
||||
<span class="text-base-content/50">{{ $t("cloud-search.window-suffix") }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Results table — matches the visual style of ContainerTable -->
|
||||
<div v-if="hits.length" class="rounded-box border-base-content/10 overflow-x-auto border">
|
||||
<table class="table-md md:table-lg table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-base-content/60 w-44 text-xs font-medium tracking-wider uppercase">
|
||||
{{ $t("cloud-search.col-time") }}
|
||||
</th>
|
||||
<th class="text-base-content/60 w-20 text-xs font-medium tracking-wider uppercase">
|
||||
{{ $t("cloud-search.col-level") }}
|
||||
</th>
|
||||
<th class="text-base-content/60 w-1 text-xs font-medium tracking-wider uppercase">
|
||||
{{ $t("cloud-search.col-container") }}
|
||||
</th>
|
||||
<th class="text-base-content/60 text-xs font-medium tracking-wider uppercase">
|
||||
{{ $t("cloud-search.col-message") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(hit, i) in hits"
|
||||
:key="`${hit.containerId}-${hit.ts}-${hit.logId ?? 0}-${i}`"
|
||||
class="hover:bg-primary/5 transition-colors"
|
||||
:class="{ 'cursor-pointer': isLive(hit) }"
|
||||
@click="isLive(hit) && openContainer(hit)"
|
||||
>
|
||||
<td class="text-base-content/70 font-mono text-xs whitespace-nowrap tabular-nums">
|
||||
{{ formatTs(hit.ts) }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-pill" :class="levelPillClass(hit.level)">{{ hit.level || "info" }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span :class="isLive(hit) ? 'text-base-content' : 'text-base-content/60'">
|
||||
{{ hit.containerName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!isLive(hit)"
|
||||
:title="$t('cloud-search.container-removed')"
|
||||
class="status-pill status-pill-neutral"
|
||||
>
|
||||
{{ $t("cloud-search.container-removed-pill") }}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<JsonFormatted
|
||||
v-if="isJson(hit.message)"
|
||||
:value="hit.message"
|
||||
:highlight="committedQuery"
|
||||
class="text-xs"
|
||||
/>
|
||||
<span v-else class="font-mono text-xs" v-html="highlight(hit.message, committedQuery)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hits.length && (cloudSearch.hasMore.value || cloudSearch.loadingMore.value)"
|
||||
class="text-base-content/60 mt-4 flex h-10 items-center justify-center text-xs"
|
||||
>
|
||||
<span v-if="cloudSearch.loadingMore.value" class="loading loading-spinner loading-xs"></span>
|
||||
</div>
|
||||
|
||||
<!-- Cloud-not-available state -->
|
||||
<div
|
||||
v-if="!cloudSearch.available.value && committedQuery"
|
||||
class="bg-base-200 border-base-content/10 rounded-box border p-8 text-center"
|
||||
>
|
||||
<mdi:cloud-off-outline class="text-base-content/40 mx-auto mb-3 size-10" />
|
||||
<p class="text-base-content/80 text-sm">
|
||||
{{
|
||||
cloudConfig?.linked ? $t("cloud-search.enable-streaming-to-search") : $t("cloud-search.connect-to-enable")
|
||||
}}
|
||||
</p>
|
||||
<RouterLink to="/settings/cloud" class="btn btn-primary btn-sm mt-4">
|
||||
{{ $t("cloud-search.cta-settings") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
import { useCloudLogSearch, type CloudLogHit } from "@/composable/cloudLogSearch";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function readQ(q: unknown): string {
|
||||
return typeof q === "string" ? q : "";
|
||||
}
|
||||
|
||||
const committedQuery = ref(readQ(route.query.q));
|
||||
|
||||
const { cloudConfig } = useCloudConfig();
|
||||
const cloudSearch = useCloudLogSearch(committedQuery);
|
||||
const hits = computed<CloudLogHit[]>(() => cloudSearch.results.value);
|
||||
|
||||
// Look up containers in the live store so we can mark hits whose containers
|
||||
// have been removed (or never existed for this Dozzle instance) as
|
||||
// non-clickable. Reactive — if a container is removed mid-session, the
|
||||
// corresponding row updates instantly.
|
||||
const containerStore = useContainerStore();
|
||||
const liveIds = computed(() => new Set(Object.keys(containerStore.allContainersById)));
|
||||
function isLive(hit: CloudLogHit): boolean {
|
||||
return liveIds.value.has(hit.containerId);
|
||||
}
|
||||
|
||||
// Infinite scroll: VueUse fires loadMore when the page is scrolled within
|
||||
// 200px of the bottom. canLoadMore short-circuits both during a fetch and
|
||||
// when the server reports no more pages, so we don't double-fire.
|
||||
useInfiniteScroll(document, () => cloudSearch.loadMore(), {
|
||||
distance: 200,
|
||||
canLoadMore: () => cloudSearch.hasMore.value && !cloudSearch.loadingMore.value,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(q) => {
|
||||
committedQuery.value = readQ(q);
|
||||
},
|
||||
);
|
||||
|
||||
function formatTs(ns: number): string {
|
||||
const d = new Date(ns / 1e6);
|
||||
const date = d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
const time = d.toLocaleTimeString([], { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
function levelPillClass(level: string): string {
|
||||
switch ((level || "").toLowerCase()) {
|
||||
case "error":
|
||||
case "fatal":
|
||||
return "status-pill-error";
|
||||
case "warn":
|
||||
case "warning":
|
||||
return "status-pill-warning";
|
||||
case "info":
|
||||
return "status-pill-primary";
|
||||
default:
|
||||
return "status-pill-neutral";
|
||||
}
|
||||
}
|
||||
|
||||
// Safe with v-html: escapeHtml runs first, then <mark> tags are added against
|
||||
// a regex anchored on the (already-escaped) needle. Don't drop the escape
|
||||
// thinking it's redundant — the message comes from indexed log content.
|
||||
function highlight(message: string, q: string): string {
|
||||
if (!q) return escapeHtml(message);
|
||||
const pattern = q.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
||||
const re = new RegExp(`(${pattern})`, "gi");
|
||||
return escapeHtml(message).replace(re, '<mark class="bg-warning text-warning-content rounded px-0.5">$1</mark>');
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
||||
);
|
||||
}
|
||||
|
||||
function isJson(message: string): boolean {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed !== null && typeof parsed === "object";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openContainer(hit: CloudLogHit) {
|
||||
// Match Dozzle's permanent-link route: /container/:id/time/:datetime?logId=...
|
||||
// hit.ts is unix nanoseconds; convert to ms then ISO 8601 with millis.
|
||||
const datetime = new Date(hit.ts / 1e6).toISOString();
|
||||
const query: Record<string, string> = {};
|
||||
if (hit.logId !== undefined && hit.logId !== 0) {
|
||||
// logId pinpoints the exact line; the historical-logs view scrolls to it.
|
||||
query.logId = String(hit.logId);
|
||||
}
|
||||
if (committedQuery.value) {
|
||||
query.q = committedQuery.value;
|
||||
}
|
||||
router.push({
|
||||
name: "/container/[id].time.[datetime]",
|
||||
params: { id: hit.containerId, datetime },
|
||||
query,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<Search />
|
||||
<HostGroupLog :name="route.params.name" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/host-group/[name]");
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
watchEffect(() => {
|
||||
setTitle(route.params.name + " group");
|
||||
});
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: host
|
||||
</route>
|
||||
+11
-3
@@ -13,7 +13,7 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ $t("label.container", { count: runningContainers.length }) }}
|
||||
</h2>
|
||||
@@ -42,7 +42,6 @@ const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
// Persist collapse state in localStorage
|
||||
const hostsCollapsed = useStorage("DOZZLE_HOSTS_COLLAPSED", false);
|
||||
const containersCollapsed = useStorage("DOZZLE_CONTAINERS_COLLAPSED", false);
|
||||
|
||||
@@ -60,7 +59,9 @@ watchEffect(() => {
|
||||
|
||||
.collapse-enter-active,
|
||||
.collapse-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
transition:
|
||||
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
max-height 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -69,4 +70,11 @@ watchEffect(() => {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.collapse-enter-active,
|
||||
.collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+187
-139
@@ -1,161 +1,206 @@
|
||||
<template>
|
||||
<PageWithLinks>
|
||||
<div class="@container flex flex-col gap-8 px-4 py-4 md:px-8">
|
||||
<section>
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("settings.about") }}</h2>
|
||||
<Links>
|
||||
<template #more-items>
|
||||
<Tag class="font-mono">{{ config.version }}</Tag>
|
||||
</template>
|
||||
</Links>
|
||||
</section>
|
||||
|
||||
<!-- ABOUT -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold tracking-tight">{{ $t("settings.about") }}</h2>
|
||||
<p class="text-base-content/60 mt-1 text-sm">{{ $t("settings.about-desc") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<span v-html="$t('settings.using-version', { version: config.version })"></span>
|
||||
<span
|
||||
v-if="hasRelease"
|
||||
v-html="$t('settings.update-available', { nextVersion: latestRelease?.name, href: latestRelease?.htmlUrl })"
|
||||
></span>
|
||||
</div>
|
||||
<div class="border-base-content/15 bg-base-200/40 divide-base-content/10 divide-y rounded-lg border">
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-2xl font-semibold tracking-tight">Dozzle</span>
|
||||
<span class="status-pill status-pill-neutral font-mono">{{ config.version }}</span>
|
||||
<a
|
||||
v-if="hasRelease"
|
||||
:href="latestRelease?.htmlUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="status-pill status-pill-warning hover:bg-warning/15"
|
||||
>
|
||||
<span class="size-1.5 rounded-full bg-current"></span>
|
||||
{{ latestRelease?.name }} available
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-base-content/60 font-mono text-xs">
|
||||
<template v-if="hasRelease && latestRelease?.createdAt">
|
||||
Latest release {{ latestRelease.name }} ·
|
||||
{{ new Date(latestRelease.createdAt).toLocaleDateString(undefined, dateFmt) }}
|
||||
</template>
|
||||
<template v-else>You're running the latest version.</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ $t("settings.help-support") }}
|
||||
|
||||
<ul class="mt-6 flex gap-2">
|
||||
<li>
|
||||
<a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer" class="btn">
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ $t("settings.support-title") }}</div>
|
||||
<div class="text-base-content/60 text-xs">{{ $t("settings.help-support") }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer" class="btn btn-sm">
|
||||
<mdi:github /> amir20/dozzle
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/sponsors/amir20"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<mdi:heart /> Sponsor on GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/amirraminfar"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary"
|
||||
class="btn btn-sm"
|
||||
>
|
||||
<mdi:beer />
|
||||
Buy me a beer
|
||||
<mdi:beer /> Buy me a beer
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("cloud.title") }}</h2>
|
||||
<!-- CLOUD -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold tracking-tight">{{ $t("cloud.title") }}</h2>
|
||||
<p class="text-base-content/60 mt-1 text-sm">{{ $t("settings.cloud-desc") }}</p>
|
||||
</div>
|
||||
<CloudSettingsCard />
|
||||
</section>
|
||||
|
||||
<section class="@container flex flex-col">
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("settings.display") }}</h2>
|
||||
<!-- DISPLAY -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold tracking-tight">{{ $t("settings.display") }}</h2>
|
||||
<p class="text-base-content/60 mt-1 text-sm">{{ $t("settings.display-desc") }}</p>
|
||||
</div>
|
||||
|
||||
<section class="grid-cols-2 gap-4 @3xl:grid">
|
||||
<div class="flex flex-col gap-4 text-balance @3xl:pr-8">
|
||||
<Toggle v-model="compact"> {{ $t("settings.compact") }} </Toggle>
|
||||
|
||||
<Toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </Toggle>
|
||||
|
||||
<Toggle v-model="showTimestamp">{{ $t("settings.show-timestamps") }}</Toggle>
|
||||
|
||||
<Toggle v-model="showStd">{{ $t("settings.show-std") }}</Toggle>
|
||||
|
||||
<Toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</Toggle>
|
||||
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.locale") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<div class="grid items-stretch gap-3 @3xl:grid-cols-2">
|
||||
<div class="border-base-content/15 bg-base-200/40 divide-base-content/10 divide-y rounded-lg border">
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
{{ $t("settings.compact") }}
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="compact" />
|
||||
</label>
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
{{ $t("settings.small-scrollbars") }}
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="smallerScrollbars" />
|
||||
</label>
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
{{ $t("settings.show-timestamps") }}
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="showTimestamp" />
|
||||
</label>
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
{{ $t("settings.show-std") }}
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="showStd" />
|
||||
</label>
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
{{ $t("settings.soft-wrap") }}
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="softWrap" />
|
||||
</label>
|
||||
<div class="flex min-h-13 flex-wrap items-center justify-between gap-3 p-4 text-sm font-medium">
|
||||
<span>{{ $t("settings.datetime-format") }}</span>
|
||||
<div class="ml-auto flex flex-wrap gap-2">
|
||||
<DropdownMenu
|
||||
v-model="locale"
|
||||
v-model="dateLocale"
|
||||
:options="[
|
||||
{ label: 'Auto', value: '' },
|
||||
...availableLocales.map((l) => ({ label: l.toLocaleUpperCase(), value: l })),
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'MM/DD/YYYY', value: 'en-US' },
|
||||
{ label: 'DD/MM/YYYY', value: 'en-GB' },
|
||||
{ label: 'DD.MM.YYYY', value: 'de-DE' },
|
||||
{ label: 'YYYY-MM-DD', value: 'en-CA' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.datetime-format") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<div class="flex gap-4">
|
||||
<DropdownMenu
|
||||
v-model="dateLocale"
|
||||
:options="[
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'MM/DD/YYYY', value: 'en-US' },
|
||||
{ label: 'DD/MM/YYYY', value: 'en-GB' },
|
||||
{ label: 'DD.MM.YYYY', value: 'de-DE' },
|
||||
{ label: 'YYYY-MM-DD', value: 'en-CA' },
|
||||
]"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-model="hourStyle"
|
||||
:options="[
|
||||
{ label: $t('settings.hour.auto'), value: 'auto' },
|
||||
{ label: $t('settings.hour.12'), value: '12' },
|
||||
{ label: $t('settings.hour.24'), value: '24' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.font-size") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<DropdownMenu
|
||||
v-model="size"
|
||||
v-model="hourStyle"
|
||||
:options="[
|
||||
{ label: $t('settings.hour.auto'), value: 'auto' },
|
||||
{ label: $t('settings.hour.12'), value: '12' },
|
||||
{ label: $t('settings.hour.24'), value: '24' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-13 flex-wrap items-center justify-between gap-3 p-4 text-sm font-medium">
|
||||
<span>{{ $t("settings.font-size") }}</span>
|
||||
<div class="join ml-auto">
|
||||
<button
|
||||
v-for="opt in [
|
||||
{ label: $t('settings.size.small'), value: 'small' },
|
||||
{ label: $t('settings.size.medium'), value: 'medium' },
|
||||
{ label: $t('settings.size.large'), value: 'large' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.color-scheme") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<DropdownMenu
|
||||
v-model="lightTheme"
|
||||
:options="[
|
||||
{ label: $t('settings.theme.auto'), value: 'auto' },
|
||||
{ label: $t('settings.theme.dark'), value: 'dark' },
|
||||
{ label: $t('settings.theme.light'), value: 'light' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
:key="opt.value"
|
||||
class="btn btn-sm join-item"
|
||||
:class="size === opt.value ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="size = opt.value as typeof size"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogList
|
||||
:messages="fakeMessages"
|
||||
:last-selected-item="undefined"
|
||||
:show-container-name="false"
|
||||
class="border-base-content/50 hidden overflow-hidden rounded-lg border shadow-sm @3xl:block"
|
||||
class="border-base-content/15 hidden h-full overflow-hidden rounded-lg border @3xl:block"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OPTIONS -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("settings.options") }}</h2>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold tracking-tight">{{ $t("settings.options") }}</h2>
|
||||
<p class="text-base-content/60 mt-1 text-sm">{{ $t("settings.options-desc") }}</p>
|
||||
</div>
|
||||
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.automatic-redirect") }}
|
||||
</template>
|
||||
<template #input>
|
||||
<div class="border-base-content/15 bg-base-200/40 divide-base-content/10 divide-y rounded-lg border">
|
||||
<div class="flex min-h-13 flex-wrap items-center justify-between gap-3 p-4 text-sm font-medium">
|
||||
<span>{{ $t("settings.locale") }}</span>
|
||||
<DropdownMenu
|
||||
class="ml-auto"
|
||||
v-model="locale"
|
||||
:options="[
|
||||
{ label: 'Auto', value: '' },
|
||||
...availableLocales.map((l) => ({ label: l.toLocaleUpperCase(), value: l })),
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-h-13 flex-wrap items-center justify-between gap-3 p-4 text-sm font-medium">
|
||||
<span>{{ $t("settings.color-scheme") }}</span>
|
||||
<div class="join ml-auto">
|
||||
<button
|
||||
v-for="opt in [
|
||||
{ label: $t('settings.theme.light'), value: 'light' },
|
||||
{ label: $t('settings.theme.dark'), value: 'dark' },
|
||||
{ label: $t('settings.theme.auto'), value: 'auto' },
|
||||
]"
|
||||
:key="opt.value"
|
||||
class="btn btn-sm join-item"
|
||||
:class="lightTheme === opt.value ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="lightTheme = opt.value as typeof lightTheme"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-13 flex-wrap items-center justify-between gap-3 p-4 text-sm font-medium">
|
||||
<span>{{ $t("settings.automatic-redirect") }}</span>
|
||||
<DropdownMenu
|
||||
class="ml-auto"
|
||||
v-model="automaticRedirect"
|
||||
:options="[
|
||||
{ label: $t('settings.redirect.instant'), value: 'instant' },
|
||||
@@ -163,14 +208,11 @@
|
||||
{ label: $t('settings.redirect.none'), value: 'none' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.group-containers") }}
|
||||
</template>
|
||||
<template #input>
|
||||
</div>
|
||||
<div class="flex min-h-13 flex-wrap items-center justify-between gap-3 p-4 text-sm font-medium">
|
||||
<span>{{ $t("settings.group-containers") }}</span>
|
||||
<DropdownMenu
|
||||
class="ml-auto"
|
||||
v-model="groupContainers"
|
||||
:options="[
|
||||
{ label: $t('settings.grouping.always'), value: 'always' },
|
||||
@@ -178,16 +220,21 @@
|
||||
{ label: $t('settings.grouping.never'), value: 'never' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</LabeledInput>
|
||||
|
||||
<Toggle v-model="search">
|
||||
{{ $t("settings.search") }} <key-shortcut char="f" class="align-top"></key-shortcut>
|
||||
</Toggle>
|
||||
|
||||
<Toggle v-model="showAllContainers">{{ $t("settings.show-stopped-containers") }}</Toggle>
|
||||
</div>
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ $t("settings.search") }}
|
||||
<key-shortcut char="f" />
|
||||
</span>
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="search" />
|
||||
</label>
|
||||
<label class="flex min-h-13 items-center justify-between gap-4 p-4 text-sm font-medium">
|
||||
{{ $t("settings.show-stopped-containers") }}
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="showAllContainers" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</PageWithLinks>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -217,6 +264,8 @@ const { t } = useI18n();
|
||||
setTitle(t("title.settings"));
|
||||
const { latestRelease, hasRelease } = useAnnouncements();
|
||||
|
||||
const dateFmt: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
|
||||
|
||||
const now = new Date();
|
||||
const hoursAgo = (hours: number) => {
|
||||
const date = new Date(now);
|
||||
@@ -260,18 +309,17 @@ const fakeMessages = computedWithControl(
|
||||
],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/main.css";
|
||||
|
||||
.has-underline {
|
||||
@apply border-base-content/50 mb-4 border-b py-2;
|
||||
|
||||
h2 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
:deep(.text-base-content\/60 a:not(.btn)),
|
||||
:deep(.text-base-content\/70 a:not(.btn)) {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
:deep(a:not(.menu a):not(.btn)) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
:deep(.text-base-content\/60 a:not(.btn):hover),
|
||||
:deep(.text-base-content\/70 a:not(.btn):hover) {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Profile {
|
||||
visibleKeys?: Map<string, Map<string[], boolean>>;
|
||||
releaseSeen?: string;
|
||||
collapsedGroups?: Set<string>;
|
||||
collapsedHostGroups?: Set<string>;
|
||||
cloudWelcomeShown?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+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,7 +7,9 @@ export type Host = {
|
||||
endpoint: string;
|
||||
available: boolean;
|
||||
dockerVersion: string;
|
||||
runtime?: "docker" | "podman";
|
||||
agentVersion: string;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const hosts = ref(
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user