mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 13:34:45 +00:00
Compare commits
333 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2eb5ec47 | |||
| 8358b98111 | |||
| af3aefe830 | |||
| ebc54a4481 | |||
| 8cf7ccd5ee | |||
| a9395e9f73 | |||
| b9df31356f | |||
| d2abd0e279 | |||
| 8a6ce3b21d | |||
| 239850c52d | |||
| 6b84105e79 | |||
| c159db5f0b | |||
| a18edf03ec | |||
| 18acf6722b | |||
| 5e0a4c43e6 | |||
| 48f04a0a7d | |||
| ae1753bcee | |||
| 3ae53dd719 | |||
| 3e0ed70362 | |||
| c6c64fdd86 | |||
| 616dd91483 | |||
| f952961eea | |||
| 3ca08f781b | |||
| 03011eea97 | |||
| 3783125b6a | |||
| fe6549e5f5 | |||
| cedb384dc4 | |||
| 163452a398 | |||
| f348b57721 | |||
| df5a60479c | |||
| 9c665d42a9 | |||
| 49f625a1db | |||
| af38e3a0c0 | |||
| 104749063d | |||
| d7b896e3d8 | |||
| 5693bcc4e4 | |||
| de70cbe389 | |||
| 73099aa67a | |||
| 9266f89776 | |||
| f88d7a1066 | |||
| f3e61606bd | |||
| 95794779d6 | |||
| e3f4566f6f | |||
| b86831d2ba | |||
| 4917984385 | |||
| c3b1c76151 | |||
| 7897a1555e | |||
| 523ac9f35e | |||
| 6e65b33161 | |||
| be2a9e0047 | |||
| fad2ea7763 | |||
| 4e7b6eea7b | |||
| a0704d5c7b | |||
| 63fa7c9e04 | |||
| 237e916390 | |||
| 6a16e35eb1 | |||
| 15b6abf843 | |||
| 540ace4e84 | |||
| c0a38587e2 | |||
| 2b1d9e889e | |||
| 5cd63429a9 | |||
| 820831b03d | |||
| 8cde0e7e48 | |||
| bd4651fb0b | |||
| 8d810b7a0b | |||
| c193e601f3 | |||
| 78ff45fdaf | |||
| ac42ce868a | |||
| 84ac9e51ac | |||
| 3062410b42 | |||
| beddc79892 | |||
| f7557ccbb5 | |||
| 6391853f3a | |||
| 619a844feb | |||
| e989caed33 | |||
| ad2b124a9e | |||
| 19c01e0fb4 | |||
| b38117bfae | |||
| 134f812336 | |||
| 55ab21b1bf | |||
| 853f46deb4 | |||
| ff0f855cf6 | |||
| 38b8511042 | |||
| 3c68345938 | |||
| acaec204b6 | |||
| 9b9c04498c | |||
| f13e169d10 | |||
| 2b23684dbd | |||
| 69b7ede7c4 | |||
| eb5c8fac1b | |||
| de368a1d97 | |||
| e283a3e361 | |||
| df11cc4740 | |||
| 791310a8b8 | |||
| d9c66f3786 | |||
| 2abcf3480e | |||
| fd0a4850f0 | |||
| 87bc48338d | |||
| 8811dc82bd | |||
| 51e13d5f97 | |||
| b37de0a16d | |||
| 743981b6e0 | |||
| c6a261c35d | |||
| 55a10273da | |||
| 38636d507f | |||
| 9ceb850d00 | |||
| c1d09ddcf1 | |||
| 62b5ef80ec | |||
| e54e14ba27 | |||
| 424abad60e | |||
| 6cdf81e4bb | |||
| 7bbddd90cb | |||
| c823617ff4 | |||
| 90ce6210ae | |||
| 21d6cd9922 | |||
| d80128684c | |||
| 7b453854ac | |||
| a51c7c1d86 | |||
| 9a99e9305f | |||
| 5bef729c52 | |||
| be352eec66 | |||
| 6a014d1e76 | |||
| a0744e836b | |||
| dd2eba59b2 | |||
| b7d9d1346a | |||
| ab6c93a679 | |||
| 1db40bc4cd | |||
| d237fa9524 | |||
| 630abbe8a6 | |||
| b1d8971a74 | |||
| 345102f498 | |||
| 58f76ee19d | |||
| 12ad969d0e | |||
| fe6444f2d7 | |||
| 65b515e533 | |||
| 4bf94b0486 | |||
| 450d5dc9de | |||
| c165120666 | |||
| 6339f9750a | |||
| cd27fe90e4 | |||
| c5907129f2 | |||
| fb4c5c7410 | |||
| d3861b8e04 | |||
| 1f4f21b98e | |||
| 99d1ae93e9 | |||
| 47233170e1 | |||
| 7b5ec0c79a | |||
| e197963251 | |||
| 262bc93310 | |||
| feece87c09 | |||
| f364ce9398 | |||
| 727b33c8cc | |||
| 118a7deeab | |||
| 0201f813ba | |||
| 1c7e804138 | |||
| b840174542 | |||
| 3895d87337 | |||
| e51a5d6392 | |||
| 425c4f0cb5 | |||
| 29086778ce | |||
| 9ef395d1a4 | |||
| 525b848d90 | |||
| 809ce73463 | |||
| 37a135804b | |||
| 2a69d5fb80 | |||
| 7699171345 | |||
| 8d0682d0f5 | |||
| 04e546e8b5 | |||
| fa5d3e4a41 | |||
| 622e54c4c6 | |||
| 694582fe64 | |||
| 0942b220d0 | |||
| e8bc72f27e | |||
| f17b758fdf | |||
| 7ee71ccafa | |||
| d1b3507709 | |||
| 1e215db588 | |||
| 5c81fdbb84 | |||
| 0431118366 | |||
| 5bc90019f8 | |||
| a5d192df6d | |||
| 5a24db9f12 | |||
| bec7d1ed2b | |||
| 5dccbf5a6e | |||
| 3e95d2c71b | |||
| bfcb14fe91 | |||
| cb3cd8e81a | |||
| 51e74db127 | |||
| cf110f2325 | |||
| 73dc6ed039 | |||
| 6a2cb3eaaa | |||
| 5e780eb767 | |||
| f69a4674a5 | |||
| c034a675c9 | |||
| 75d8bfb3e5 | |||
| abb510f3c9 | |||
| 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 | |||
| 6a6b34b35d | |||
| 0346c1e09c | |||
| 9d008da270 | |||
| 797faf079c | |||
| 92ae58c058 | |||
| 816dccc8c1 | |||
| ac2ca7c280 | |||
| 8c5338dea8 | |||
| d09b2fe04e | |||
| 2994ccf239 | |||
| f8e6704c34 | |||
| 563ed6319c | |||
| 2c3bbe3c6b | |||
| 731d09b2e5 | |||
| 799d261c59 | |||
| abac4fdf7e | |||
| ac43dca1ac | |||
| 6c4b386283 | |||
| 52e61158cf | |||
| 3aa80b8f07 | |||
| 4616159e27 | |||
| 3dbbe530db | |||
| 27fdd047a5 | |||
| c9e89fb425 | |||
| 1cd0b651c3 | |||
| 7ce3bd8e64 | |||
| 915478c4ab | |||
| d58a33fdac | |||
| 94976bdf58 | |||
| a1aa2253bc | |||
| 09e549b1b1 | |||
| a0877bbadd | |||
| 04515cc1bd | |||
| 3c10eefcfe | |||
| 66d9fa4dd1 | |||
| f195290a30 | |||
| fe8165ec09 | |||
| 09647174ee | |||
| 6d8f3005b0 | |||
| af4412f667 | |||
| f5f736b6ff | |||
| 44239614d1 | |||
| f13bcdba60 | |||
| 92fa5beb07 | |||
| 4c378443e0 | |||
| e608bdf201 | |||
| d873edee0e | |||
| fe83ddcf22 | |||
| 338c953298 | |||
| 2d54f43ef5 | |||
| 4b913ee68a | |||
| dd5cd3ccac | |||
| 5abda9f224 | |||
| 32fac82635 | |||
| cae201d592 | |||
| 284822c631 | |||
| 475a16321d | |||
| 3ab758aabf | |||
| 7966b21249 | |||
| 15e13507b2 | |||
| 3254f36246 | |||
| 3c6bc6b391 | |||
| e079e5fc43 | |||
| 317c141f65 | |||
| 77a7905f2f | |||
| cfcaebe36a | |||
| 7225abec25 | |||
| af241022bf | |||
| 806b6fb0cd | |||
| dca9f17be4 | |||
| 3ce1f9ebdc | |||
| 481455a09b | |||
| 77ddc3ce52 | |||
| 9b44236ad4 | |||
| 591d881294 | |||
| 4cf3952a73 | |||
| 3a309ce8fd | |||
| 922d6de14e | |||
| e271551a5d | |||
| f60fbc254a | |||
| fd943272f0 | |||
| 2380c87bb0 | |||
| 166037617f | |||
| aa428b6c2c | |||
| 9c9d3613d6 | |||
| 6bb3c4256a | |||
| 21302b4173 | |||
| dc38dde2d8 | |||
| 2975a9ceda | |||
| 6e0c028439 | |||
| e7c83b6a2f | |||
| 4f7695d494 | |||
| fc1e86fc14 | |||
| 6e3e82e806 | |||
| 8957fe3a6f | |||
| abdcbe835e | |||
| 0db989b193 |
@@ -5,7 +5,7 @@ tmp_dir = "tmp"
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dozzle"
|
||||
cmd = "GOEXPERIMENT=jsonv2 go build -race -o ./tmp/dozzle ."
|
||||
cmd = "go build -race -o ./tmp/dozzle ."
|
||||
delay = 1000
|
||||
exclude_dir = [
|
||||
"assets",
|
||||
|
||||
@@ -28,3 +28,10 @@
|
||||
- Subscription fields use atomic types: TriggerCount (atomic.Int64), LastTriggeredAt (atomic.Pointer)
|
||||
- MetricCooldowns uses xsync.Map for per-container cooldown tracking
|
||||
- sendSem (semaphore.Weighted=5) limits concurrent notification sends
|
||||
|
||||
### Container Update (feat/container-update)
|
||||
|
||||
- **progressCh close contract**: Docker and Agent implementations close progressCh via defer; K8s does NOT, causing handler hang
|
||||
- **NetworkSettings nil risk**: `docker.InspectResponse.NetworkSettings` is a pointer; `ContainerCreate` doesn't nil-check before accessing `.Networks`
|
||||
- **Destructive recreate**: stop->remove->create->start has no rollback if create fails after remove
|
||||
- **SSE parsing in frontend**: Uses manual ReadableStream reader, not EventSource; no AbortController cleanup on unmount
|
||||
|
||||
@@ -28,3 +28,9 @@
|
||||
- `internal/notification/log_listener.go` -- Log stream management
|
||||
- `internal/notification/manager.go` -- Subscription CRUD and listener orchestration
|
||||
- `internal/notification/types.go` -- Subscription matching (expr evaluation)
|
||||
- `internal/cloud/client.go` -- Cloud gRPC bidirectional streaming client
|
||||
- `internal/cloud/tools.go` -- Tool definitions and execution dispatch
|
||||
|
||||
## Memory Files
|
||||
|
||||
- [cloud_client_patterns.md](cloud_client_patterns.md) - Cloud gRPC client architecture and known patterns
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Cloud gRPC Client Patterns
|
||||
description: Architecture of internal/cloud/ package - gRPC bidirectional streaming for cloud tool calls
|
||||
type: project
|
||||
---
|
||||
|
||||
Cloud client (`internal/cloud/client.go`) uses a bidirectional gRPC stream (`ToolStream`) to receive tool requests from Dozzle Cloud and send responses.
|
||||
|
||||
Key architecture:
|
||||
|
||||
- `Client.Run()` is the reconnect loop with exponential backoff
|
||||
- `Client.connect()` creates a new `grpc.ClientConn` per reconnect (potential optimization: reuse connection)
|
||||
- `sendMu sync.Mutex` protects concurrent `stream.Send()` calls (tool calls dispatch to goroutines)
|
||||
- `toolSem` (weighted semaphore, max 5) limits concurrent tool execution
|
||||
- `apiKeyFunc` closure provides cloud API key; empty string means no cloud configured
|
||||
- PermissionDenied from server causes permanent stop (no retry)
|
||||
- Tool definitions cached via `sync.Once` + `cachedTools` field (fixed from prior re-serialization issue)
|
||||
- `Notify()` / `startCh` pattern ensures zero overhead for non-cloud users
|
||||
- Tool dispatch in `tools.go` uses typed proto responses (`CallToolResponse` with oneof `Result`)
|
||||
|
||||
Known perf issues found (2026-04):
|
||||
|
||||
- `containsIgnoreCase` in `tools_helpers.go` allocates two lowered strings per call; used in filter loops
|
||||
- `executeInspectContainer` calls `buildHostNameMap` to resolve a single host name
|
||||
- `executeFetchContainerLogs` doesn't drain log channel after early break (potential goroutine leak)
|
||||
- `fmt.Sprintf("%v", event.Message)` in log path uses reflect-based formatting
|
||||
|
||||
**How to apply:** When reviewing future changes to this package, watch for: undrained channels from streaming APIs, per-call allocations in filter loops, and unnecessary API calls for single lookups.
|
||||
@@ -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@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.14.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.1
|
||||
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@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.14.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.0.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.0.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.0.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@v5
|
||||
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.0.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.0.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
|
||||
@@ -39,18 +39,21 @@ jobs:
|
||||
images: |
|
||||
amir20/dozzle
|
||||
ghcr.io/amir20/dozzle
|
||||
- name: Short SHA
|
||||
id: sha
|
||||
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}-${{ steps.sha.outputs.short }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -24,17 +24,23 @@ 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.14.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@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build with VitePress
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
pnpm docs:build
|
||||
touch docs/.vitepress/dist/.nojekyll
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
@@ -57,4 +63,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
+26
-25
@@ -15,18 +15,17 @@ jobs:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.14.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.14.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@v5
|
||||
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.14.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.1"
|
||||
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.1"
|
||||
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@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.14.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.14.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:
|
||||
@@ -112,13 +129,22 @@ The Go backend is organized into these key packages:
|
||||
- Uses Protocol Buffers (protos defined in `protos/`)
|
||||
- Enables distributed log collection across Docker hosts
|
||||
|
||||
- **`internal/cloud/`** - Dozzle Cloud integration (tool execution engine)
|
||||
- `client.go`: Bidirectional gRPC stream client with auto-reconnect and exponential backoff
|
||||
- `tools.go`: Tool registration, dispatch (`executeTool`), and `ToolHostService` interface
|
||||
- `tools_containers.go`: Container listing, finding, stats, and inspection tools
|
||||
- `tools_logs.go`: Log fetching with level/query/regex filtering (max 100 lines)
|
||||
- `tools_actions.go`: Container start/stop/restart actions (gated by `enableActions`)
|
||||
- `tools_helpers.go`: Proto conversion utilities and host name resolution
|
||||
- Uses `protos/cloud.proto` for service and message definitions
|
||||
|
||||
- **`internal/k8s/`** - Kubernetes client support
|
||||
- Alternative to Docker client for k8s deployments
|
||||
|
||||
- **`internal/support/`** - Support utilities
|
||||
- `cli/`: Command-line argument parsing and validation
|
||||
- `docker/`: Multi-host Docker management and Swarm support
|
||||
- `container/`: Container service abstractions
|
||||
- `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
|
||||
@@ -134,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)
|
||||
@@ -198,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
|
||||
|
||||
@@ -228,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
|
||||
|
||||
@@ -236,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
|
||||
@@ -306,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
|
||||
@@ -339,6 +357,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
5. Streaming RPCs (logs, stats, events) use bidirectional channels
|
||||
6. Responses converted back to domain models via `FromProto()` methods
|
||||
|
||||
### Cloud Tool Execution Flow
|
||||
|
||||
1. `cloud.Client.Run()` blocks until `Notify()` signals a cloud dispatcher is configured
|
||||
2. `connect()` establishes bidirectional gRPC stream (`ToolStream` RPC) to cloud endpoint
|
||||
3. Cloud sends `ToolRequest` (ListTools or CallTool), client dispatches via `executeTool()`
|
||||
4. Tool calls run concurrently (max 5 via weighted semaphore), responses sent back on stream
|
||||
5. On disconnect, exponential backoff (1s→30s with jitter) triggers reconnection
|
||||
6. `PermissionDenied` errors stop retrying permanently (invalid API key / no pro plan)
|
||||
7. Tool definitions cached via `sync.Once`; zero overhead for non-cloud users
|
||||
|
||||
### Log Parsing Pipeline
|
||||
|
||||
1. Docker API returns multiplexed stream (8-byte headers + payload)
|
||||
@@ -376,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`
|
||||
@@ -405,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:**
|
||||
@@ -413,7 +433,16 @@ 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
|
||||
|
||||
1. Define the tool in `AvailableTools()` in `internal/cloud/tools.go` with name, description, and parameter schema
|
||||
2. Add a response message type in `protos/cloud.proto` and add it to the `CallToolResponse.result` oneof
|
||||
3. Run `make generate` to regenerate protobuf code
|
||||
4. Add a case in the `executeTool()` switch in `internal/cloud/tools.go`
|
||||
5. Implement the execution function in the appropriate `tools_*.go` file
|
||||
6. Use `ToolHostService` interface methods to access container/host data
|
||||
7. Add tests in `tools_test.go`
|
||||
|
||||
## Common Development Patterns
|
||||
|
||||
@@ -436,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
|
||||
|
||||
+11
-5
@@ -1,17 +1,19 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:25.8.1-alpine AS node
|
||||
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
|
||||
|
||||
@@ -34,6 +39,7 @@ RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
|
||||
# Copy all other files
|
||||
COPY internal ./internal
|
||||
COPY proto ./proto
|
||||
COPY types ./types
|
||||
COPY main.go ./
|
||||
COPY protos ./protos
|
||||
@@ -48,7 +54,7 @@ ARG TARGETOS TARGETARCH
|
||||
|
||||
# Build binary
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Dozzle Major Version Highlights
|
||||
|
||||
A summary of major features introduced with each major version bump since 2020.
|
||||
|
||||
## v2.0.0 — 2020-06-27
|
||||
|
||||
- Theme overhaul using CSS variables
|
||||
- Progress notification bar
|
||||
- Toggle between new light and dark themes
|
||||
|
||||
## v3.0.0 — 2020-09-06
|
||||
|
||||
- Live container stats (CPU/memory) streamed in real time
|
||||
- Pinned tabs and improved mobile/responsive layout
|
||||
- Major UI overhaul
|
||||
|
||||
## v4.0.0 — 2022-08-17
|
||||
|
||||
- First-class JSON log support
|
||||
- Jump-to-context, soft wraps, clear-logs action
|
||||
- Auto color scheme, dark mode polish
|
||||
- Container stats panel, total CPU/mem usage
|
||||
- Healthcheck endpoint and "wait for docker" startup option
|
||||
|
||||
## v5.0.0 — 2023-09-23
|
||||
|
||||
- Multi-host support with parallel client connections
|
||||
- New homepage dashboard with all containers, sortable table, pagination, bar charts
|
||||
- Exponential moving average for stats
|
||||
- Container pinning, keyboard shortcut overlay
|
||||
- stdout/stderr stream separation
|
||||
- i18n: Chinese, German added; locale infrastructure
|
||||
- Refactored UI with faster components
|
||||
|
||||
## v6.0.0 — 2024-01-01
|
||||
|
||||
- **Forward-proxy authentication** (Authelia, etc.) and `users.yml` file-based auth
|
||||
- Container actions: start/stop/restart from the UI
|
||||
- Hot-reload of users.yml without restart
|
||||
- Custom headers for forward-proxy auth
|
||||
- Settings synced to disk for authenticated users
|
||||
- Toast notifications, release list, redirect-to-new-container
|
||||
- Removed legacy auth model (breaking)
|
||||
|
||||
## v7.0.0 — 2024-05-24
|
||||
|
||||
- **Docker Swarm mode** with stacks and services on remote hosts
|
||||
- Host cards on dashboard with per-host stats
|
||||
- Container grouping by stack/compose
|
||||
- Background stats collection (up to 5 min) with idle deactivation
|
||||
- LogFmt parser support
|
||||
- Compact mode, draggable search, alt-click split panes
|
||||
- Many new locales (French, Italian, Polish, Danish, Turkish, …)
|
||||
- `generate` subcommand for users.yml
|
||||
|
||||
## v8.0.0 — 2024-07-05
|
||||
|
||||
- **Swarm mode rebuilt on gRPC agents** (breaking architecture change)
|
||||
- Critical/severe log levels
|
||||
- Stacks and services in fuzzy search
|
||||
- Improved search with full match scrolling
|
||||
- Container start events shown inline
|
||||
|
||||
## v9.0.0 — 2026-01-06
|
||||
|
||||
- **Kubernetes mode** with k8s-specific menu
|
||||
- **User roles** and `dozzle_*` role mapping; logout URL for forward proxy
|
||||
- Historical stats on homepage, hosts, and containers
|
||||
- Permanent links to specific past log lines
|
||||
- Grouping by `dev.dozzle.group` label, log message grouping
|
||||
- Shell resize support, action toolbar in menu
|
||||
- Settings page, collapsible side menu sections
|
||||
- Parallel container fetching, gRPC compression
|
||||
- CLEF (`@l`) log level extraction
|
||||
- New locales: Korean, Indonesian, Dutch
|
||||
|
||||
## v10.0.0 — 2026-02-10
|
||||
|
||||
- **Dozzle Cloud** integration (bidirectional gRPC tool execution)
|
||||
- **Notifications & alerts**: full notifications page, webhooks, dispatchers, Go template support, test connections
|
||||
- Notifications work across agents
|
||||
- Per-container network usage stats (with mobile view)
|
||||
- Coolify label fallbacks for container name/group
|
||||
- Alert creation shortcut, JSON syntax in templates
|
||||
@@ -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: shared_key.pem shared_cert.pem
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle:local .
|
||||
docker: generate
|
||||
@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,14 +53,11 @@ 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:latest 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
|
||||
|
||||
.PHONY: preview
|
||||
preview: build
|
||||
pnpm preview
|
||||
|
||||
|
||||
@@ -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
+44
-5
@@ -6,10 +6,12 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
@@ -17,6 +19,7 @@ declare global {
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const colorize: typeof import('./utils/index').colorize
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
@@ -28,7 +31,9 @@ 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
|
||||
const createExprEditor: typeof import('./composable/exprEditor').createExprEditor
|
||||
const createGlobalState: typeof import('@vueuse/core').createGlobalState
|
||||
@@ -53,6 +58,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
|
||||
@@ -63,10 +69,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
|
||||
@@ -80,7 +89,7 @@ declare global {
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const lightTheme: typeof import('./stores/settings').lightTheme
|
||||
const loadBetween: typeof import('./composable/eventStreams').loadBetween
|
||||
const loadBetween: typeof import('./composable/loadBetween').loadBetween
|
||||
const locale: typeof import('./stores/settings').locale
|
||||
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
|
||||
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
||||
@@ -113,6 +122,8 @@ 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
|
||||
const pinnedContainers: typeof import('./composable/storage').pinnedContainers
|
||||
@@ -156,6 +167,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
|
||||
@@ -166,6 +178,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
|
||||
@@ -203,7 +216,10 @@ declare global {
|
||||
const useClipboard: typeof import('@vueuse/core').useClipboard
|
||||
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
|
||||
@@ -241,6 +257,7 @@ declare global {
|
||||
const useEventListener: typeof import('@vueuse/core').useEventListener
|
||||
const useEventSource: typeof import('@vueuse/core').useEventSource
|
||||
const useExponentialMovingAverage: typeof import('./utils/index').useExponentialMovingAverage
|
||||
const useExprEditorField: typeof import('./composable/useExprEditorField').useExprEditorField
|
||||
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
|
||||
const useFavicon: typeof import('@vueuse/core').useFavicon
|
||||
const useFetch: typeof import('@vueuse/core').useFetch
|
||||
@@ -250,11 +267,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
|
||||
@@ -270,6 +289,7 @@ declare global {
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLink: typeof import('vue-router/auto').useLink
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
const useLogLoader: typeof import('./composable/logLoader').useLogLoader
|
||||
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
|
||||
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
|
||||
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
|
||||
@@ -401,10 +421,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'
|
||||
@@ -419,7 +445,7 @@ declare global {
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
@@ -442,6 +468,7 @@ declare module 'vue' {
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
|
||||
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
|
||||
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
|
||||
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
@@ -453,7 +480,9 @@ 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']>
|
||||
readonly createExprEditor: UnwrapRef<typeof import('./composable/exprEditor')['createExprEditor']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
@@ -488,8 +517,10 @@ declare module 'vue' {
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -505,7 +536,7 @@ declare module 'vue' {
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
|
||||
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
|
||||
readonly loadBetween: UnwrapRef<typeof import('./composable/loadBetween')['loadBetween']>
|
||||
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
|
||||
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
@@ -538,6 +569,8 @@ 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']>
|
||||
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
|
||||
@@ -560,7 +593,6 @@ declare module 'vue' {
|
||||
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
||||
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly scrollContextKey: UnwrapRef<typeof import('./composable/scrollContext')['scrollContextKey']>
|
||||
readonly search: UnwrapRef<typeof import('./stores/settings')['search']>
|
||||
readonly sessionHost: UnwrapRef<typeof import('./composable/storage')['sessionHost']>
|
||||
@@ -628,7 +660,10 @@ declare module 'vue' {
|
||||
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
|
||||
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']>
|
||||
@@ -666,6 +701,7 @@ declare module 'vue' {
|
||||
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
|
||||
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
|
||||
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
|
||||
readonly useExprEditorField: UnwrapRef<typeof import('./composable/useExprEditorField')['useExprEditorField']>
|
||||
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
|
||||
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
|
||||
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
|
||||
@@ -675,11 +711,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']>
|
||||
@@ -695,6 +733,7 @@ declare module 'vue' {
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useLogLoader: UnwrapRef<typeof import('./composable/logLoader')['useLogLoader']>
|
||||
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
|
||||
@@ -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
+33
-3
@@ -27,6 +27,7 @@ declare module 'vue' {
|
||||
'Carbon:star': typeof import('~icons/carbon/star')['default']
|
||||
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
||||
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
|
||||
'Carbon:upgrade': typeof import('~icons/carbon/upgrade')['default']
|
||||
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
||||
Carousel: typeof import('./components/common/Carousel.vue')['default']
|
||||
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
|
||||
@@ -35,6 +36,9 @@ declare module 'vue' {
|
||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||
'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']
|
||||
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
|
||||
@@ -45,11 +49,13 @@ declare module 'vue' {
|
||||
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
|
||||
CooldownField: typeof import('./components/Notification/CooldownField.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
DestinationCard: typeof import('./components/Notification/DestinationCard.vue')['default']
|
||||
DestinationForm: typeof import('./components/Notification/DestinationForm.vue')['default']
|
||||
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
|
||||
EventAlertFields: typeof import('./components/Notification/EventAlertFields.vue')['default']
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
@@ -57,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']
|
||||
@@ -87,14 +98,19 @@ 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']
|
||||
'Mdi:bellRingOutline': typeof import('~icons/mdi/bell-ring-outline')['default']
|
||||
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
|
||||
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
|
||||
'Mdi:check': typeof import('~icons/mdi/check')['default']
|
||||
@@ -105,22 +121,31 @@ 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']
|
||||
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
|
||||
'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default']
|
||||
'Mdi:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['default']
|
||||
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
|
||||
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
|
||||
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
|
||||
'Mdi:poll': typeof import('~icons/mdi/poll')['default']
|
||||
'Mdi:refresh': typeof import('~icons/mdi/refresh')['default']
|
||||
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
|
||||
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
|
||||
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
|
||||
@@ -139,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']
|
||||
@@ -159,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']
|
||||
@@ -177,7 +205,9 @@ 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,158 @@
|
||||
<template>
|
||||
<Dropdown class="dropdown-end" @click="onOpen">
|
||||
<template #trigger>
|
||||
<div class="relative">
|
||||
<mdi:cloud
|
||||
class="size-6"
|
||||
:class="
|
||||
!cloudConfig
|
||||
? 'text-base-content/40'
|
||||
: cloudConfig.linked && !cloudStatusError
|
||||
? 'text-info'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'text-warning'
|
||||
: 'text-error'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="cloudConfig?.linked"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'bg-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'bg-warning'
|
||||
: cloudStatusError
|
||||
? 'bg-error'
|
||||
: 'bg-success'
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-80 space-y-3 p-1">
|
||||
<!-- Not linked -->
|
||||
<template v-if="!cloudConfig">
|
||||
<div class="flex flex-col items-center gap-2 p-2 text-center">
|
||||
<mdi:cloud class="text-base-content/40 text-4xl" />
|
||||
<h3 class="text-base font-bold">{{ $t("cloud.title") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("cloud.description") }}</p>
|
||||
<div class="mt-2 flex w-full gap-2">
|
||||
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.learn-more") }}
|
||||
</a>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm flex-1">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.link-instance") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<div v-if="cloudStatusError" class="space-y-3">
|
||||
<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" />
|
||||
<span class="text-sm">{{
|
||||
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm w-full">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.relink-instance") }}
|
||||
</a>
|
||||
<button v-else class="btn btn-sm w-full" @click="fetchCloudStatus">
|
||||
<mdi:refresh class="text-base" />
|
||||
{{ $t("button.retry") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center justify-center gap-2 py-4">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<div v-else-if="cloudStatus" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-bold">{{ $t("cloud.title") }}</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-success badge-sm">{{ $t("cloud.connected") }}</span>
|
||||
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
</div>
|
||||
</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() }}
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
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>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.dashboard") }}
|
||||
</a>
|
||||
<a :href="`${cloudUrl}/settings`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.settings") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<WelcomeModal ref="welcomeModal" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
|
||||
|
||||
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
|
||||
useCloudConfig();
|
||||
|
||||
const welcomeModal = ref<{ open: () => void }>();
|
||||
const cloudWelcomeShown = useProfileStorage("cloudWelcomeShown", false);
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
if (cloudConfig.value?.linked && !cloudStatus.value && !isLoadingCloudStatus.value) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCloudConfig();
|
||||
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
|
||||
// Handle successful OAuth return — show welcome modal
|
||||
if (window.location.hash === "#cloudLinked" && !cloudWelcomeShown.value) {
|
||||
cloudWelcomeShown.value = true;
|
||||
nextTick(() => welcomeModal.value?.open());
|
||||
history.replaceState(history.state, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<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 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>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
|
||||
{{ $t("cloud.learn-more") }}
|
||||
</a>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.link-instance") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<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" />
|
||||
<span class="text-sm">{{
|
||||
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.relink-instance") }}
|
||||
</a>
|
||||
<button v-else class="btn btn-sm" @click="fetchCloudStatus">
|
||||
<mdi:refresh class="text-base" />
|
||||
{{ $t("button.retry") }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-error" @click="confirmUnlink">
|
||||
<mdi:link-variant-off class="text-base" />
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 p-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<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 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"
|
||||
: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>
|
||||
|
||||
<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>
|
||||
<button class="btn btn-sm btn-error" @click="confirmUnlink">
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Unlink confirmation modal -->
|
||||
<dialog ref="unlinkModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ $t("cloud.unlink") }}</h3>
|
||||
<p class="py-4 text-sm">{{ $t("cloud.unlink-confirm") }}</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm">{{ $t("button.cancel") }}</button>
|
||||
</form>
|
||||
<button class="btn btn-error btn-sm" :disabled="isUnlinking" @click="doUnlink">
|
||||
<span v-if="isUnlinking" class="loading loading-spinner loading-xs"></span>
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button></button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
|
||||
|
||||
const {
|
||||
cloudConfig,
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
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;
|
||||
});
|
||||
|
||||
function confirmUnlink() {
|
||||
unlinkModal.value?.showModal();
|
||||
}
|
||||
|
||||
async function doUnlink() {
|
||||
isUnlinking.value = true;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = "unavailable";
|
||||
return;
|
||||
}
|
||||
clearCloudState();
|
||||
unlinkModal.value?.close();
|
||||
} finally {
|
||||
isUnlinking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initialLoad;
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
/>
|
||||
</div>
|
||||
<div class="join">
|
||||
<div class="join max-md:hidden">
|
||||
<button
|
||||
class="btn join-item btn-xs md:btn-sm"
|
||||
:class="statMode === 'chart' ? 'btn-active' : 'btn-ghost'"
|
||||
@@ -75,7 +75,7 @@
|
||||
v-show="isVisible(key)"
|
||||
>
|
||||
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
|
||||
<span>{{ $t(value.label) }}</span>
|
||||
<span>{{ $t(isMobile && value.mobileLabel ? value.mobileLabel : value.label) }}</span>
|
||||
<span class="h-4" data-icon>
|
||||
<mdi:arrow-up />
|
||||
</span>
|
||||
@@ -87,10 +87,10 @@
|
||||
<tr
|
||||
v-for="container in paginated"
|
||||
:key="container.id"
|
||||
v-memo="[container.id, statMode]"
|
||||
v-memo="[container.id, statMode, isMobile]"
|
||||
class="hover:bg-base-100/80!"
|
||||
>
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate">
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate max-md:max-w-32">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
@@ -142,6 +142,7 @@ const fields: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
mobileLabel?: string;
|
||||
sortFunc: (a: Container, b: Container) => number;
|
||||
mobileVisible: boolean;
|
||||
customClass?: string;
|
||||
@@ -149,6 +150,7 @@ const fields: Record<
|
||||
> = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
mobileLabel: "label.name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
@@ -167,21 +169,23 @@ const fields: Record<
|
||||
created: {
|
||||
label: "label.created",
|
||||
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
|
||||
mobileVisible: true,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
cpu: {
|
||||
label: "label.avg-cpu",
|
||||
mobileLabel: "label.cpu",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
mobileVisible: true,
|
||||
customClass: "min-w-48 max-md:min-w-0",
|
||||
},
|
||||
mem: {
|
||||
label: "label.avg-mem",
|
||||
mobileLabel: "label.mem",
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.movingAverage.memoryUsage - b.movingAverage.memoryUsage) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "min-w-48",
|
||||
mobileVisible: true,
|
||||
customClass: "min-w-48 max-md:min-w-0",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<li v-if="!historical">
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="hasComplexLogs">
|
||||
@@ -154,6 +154,12 @@
|
||||
{{ $t("toolbar.restart") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="update()" :disabled="actionStates.update">
|
||||
<carbon:upgrade />
|
||||
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template v-if="enableShell && !historical">
|
||||
@@ -190,10 +196,10 @@ const showDrawer = useDrawer();
|
||||
|
||||
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
|
||||
const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
|
||||
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();
|
||||
|
||||
@@ -293,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");
|
||||
@@ -302,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();
|
||||
@@ -360,10 +366,25 @@ a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
/* daisyUI's .menu is width: fit-content, so nested submenus (Streams, Levels)
|
||||
* shrink to their content and the hover highlight stops short. Stretch them to
|
||||
* fill the dropdown so the row highlight spans the full width. */
|
||||
.menu li ul {
|
||||
margin-inline-start: 0;
|
||||
width: 100%;
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the solid level colors, but use white labels in the light theme so the
|
||||
* text reads against the saturated chip backgrounds. warn is a light orange,
|
||||
* where dark text has better contrast than white, so it keeps the default. */
|
||||
[data-theme="light"] .badge[data-level="info"],
|
||||
[data-theme="light"] .badge[data-level="debug"],
|
||||
[data-theme="light"] .badge[data-level="trace"],
|
||||
[data-theme="light"] .badge[data-level="error"],
|
||||
[data-theme="light"] .badge[data-level="fatal"] {
|
||||
color: oklch(100% 0 0) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
|
||||
{{ container.hostLabel }}
|
||||
</li>
|
||||
<li>
|
||||
<template v-if="otherContainers.length === 0">{{ container.name }}</template>
|
||||
<li class="min-w-0">
|
||||
<template v-if="otherContainers.length === 0"
|
||||
><span class="block truncate">{{ container.name }}</span></template
|
||||
>
|
||||
<div v-else>
|
||||
<div class="dropdown">
|
||||
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
|
||||
@@ -25,12 +27,13 @@
|
||||
<li v-for="other in otherContainers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div v-else>{{ other.name }}</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
@@ -42,8 +45,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
<VolumeWarning :container="container" />
|
||||
<Tag
|
||||
class="group hidden! cursor-pointer items-center gap-1.5 pr-1! font-mono @md:inline-flex!"
|
||||
size="small"
|
||||
role="button"
|
||||
:title="$t('toolbar.copy-image')"
|
||||
:aria-label="$t('toolbar.copy-image')"
|
||||
@click="copyImage"
|
||||
>
|
||||
<span class="truncate">{{ imageTag }}</span>
|
||||
<span
|
||||
class="bg-base-content/10 text-base-content/40 group-hover:text-base-content/70 flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<mdi:content-copy class="size-3" />
|
||||
</span>
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,6 +68,21 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||
const { showToast } = useToast();
|
||||
|
||||
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
|
||||
|
||||
async function copyImage() {
|
||||
if (!isSupported.value) return;
|
||||
await copy(imageTag.value);
|
||||
if (copied.value) {
|
||||
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
|
||||
}
|
||||
}
|
||||
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.name),
|
||||
set: (value) => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div v-if="worst" class="dropdown dropdown-end" :class="{ 'dropdown-bottom': !openUp }">
|
||||
<button
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium tabular-nums"
|
||||
:class="badgeClass"
|
||||
:title="title"
|
||||
>
|
||||
<PhWarning class="size-3.5" />
|
||||
<span class="max-md:hidden">{{ worst.destination }}</span>
|
||||
<span>{{ formatPct(worst.pct) }}</span>
|
||||
</button>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 mt-1 w-72 border p-2 text-xs shadow-sm"
|
||||
>
|
||||
<div class="text-base-content/60 mb-1.5 px-1 text-[11px] tracking-wide uppercase">{{ t("tooltip.volumes") }}</div>
|
||||
<ul class="space-y-1.5">
|
||||
<li
|
||||
v-for="m in sortedMounts"
|
||||
:key="m.destination"
|
||||
class="flex flex-col gap-1 rounded p-1.5"
|
||||
:class="rowClass(m.pct, m.available)"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="truncate font-mono text-[11.5px]" :title="m.destination">{{ m.destination }}</span>
|
||||
<span v-if="m.available" class="tabular-nums">{{ formatPct(m.pct) }}</span>
|
||||
<span v-else class="text-base-content/50">n/a</span>
|
||||
</div>
|
||||
<div v-if="m.available" class="bg-base-content/10 h-1 w-full overflow-hidden rounded">
|
||||
<div class="h-full" :class="barClass(m.pct)" :style="{ width: Math.min(100, m.pct * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="text-base-content/60 flex justify-between tabular-nums">
|
||||
<span v-if="m.available">{{ formatBytes(m.used) }} / {{ formatBytes(m.total) }}</span>
|
||||
<span v-else>{{ t("tooltip.volume-unreachable") }}</span>
|
||||
<RelativeTime v-if="m.lastChecked" :date="m.lastChecked" class="text-[10.5px]" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import PhWarning from "~icons/ph/warning-fill";
|
||||
|
||||
const WARN = 0.85;
|
||||
const CRITICAL = 0.95;
|
||||
|
||||
const { container } = defineProps<{ container: Container; openUp?: boolean }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface DerivedMount {
|
||||
destination: string;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
available: boolean;
|
||||
pct: number;
|
||||
lastChecked?: Date;
|
||||
}
|
||||
|
||||
const mounts = computed<DerivedMount[]>(() => {
|
||||
const raw = container.mountStats ?? {};
|
||||
return Object.values(raw).map((m) => ({
|
||||
destination: m.destination,
|
||||
total: m.total,
|
||||
used: m.used,
|
||||
free: m.free,
|
||||
available: m.available && m.total > 0,
|
||||
pct: m.available && m.total > 0 ? m.used / m.total : 0,
|
||||
lastChecked: m.lastChecked ? new Date(m.lastChecked) : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
const sortedMounts = computed(() => [...mounts.value].sort((a, b) => b.pct - a.pct));
|
||||
|
||||
const worst = computed(() => {
|
||||
const candidate = sortedMounts.value.find((m) => m.available && m.pct >= WARN);
|
||||
return candidate ?? null;
|
||||
});
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
if (!worst.value) return "";
|
||||
if (worst.value.pct >= CRITICAL) return "bg-error/15 text-error hover:bg-error/25";
|
||||
return "bg-warning/15 text-warning hover:bg-warning/25";
|
||||
});
|
||||
|
||||
function rowClass(pct: number, available: boolean) {
|
||||
if (!available) return "bg-base-content/[0.04]";
|
||||
if (pct >= CRITICAL) return "bg-error/10";
|
||||
if (pct >= WARN) return "bg-warning/10";
|
||||
return "bg-base-content/[0.04]";
|
||||
}
|
||||
|
||||
function barClass(pct: number) {
|
||||
if (pct >= CRITICAL) return "bg-error";
|
||||
if (pct >= WARN) return "bg-warning";
|
||||
return "bg-success";
|
||||
}
|
||||
|
||||
function formatPct(pct: number) {
|
||||
return `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (!worst.value) return "";
|
||||
return t("tooltip.volume-full", { destination: worst.value.destination, pct: formatPct(worst.value.pct) });
|
||||
});
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
|
||||
import FuzzySearchModal from "./FuzzySearchModal.vue";
|
||||
|
||||
import { Container } from "@/models/Container";
|
||||
import { lightTheme } from "@/stores/settings";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -16,7 +17,7 @@ vi.mock("vue-router");
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,7 +292,7 @@ const list = computed(() => {
|
||||
return items;
|
||||
});
|
||||
|
||||
const { results } = useFuse(query, list, {
|
||||
const { results: fuseResults } = useFuse(query, list, {
|
||||
fuseOptions: {
|
||||
keys: ["name", "host"],
|
||||
includeScore: true,
|
||||
@@ -154,6 +302,22 @@ const { results } = 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) {
|
||||
@@ -170,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) {
|
||||
@@ -191,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>
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<mdi:bell class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<CloudPopover />
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/settings' }"
|
||||
:aria-label="$t('title.settings')"
|
||||
@@ -21,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>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
</button>
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
|
||||
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<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>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm" :class="{ 'opacity-60': !alert.enabled }">
|
||||
<div
|
||||
class="card bg-base-100 shadow-sm"
|
||||
:class="{ 'opacity-60': !alert.enabled, 'highlight-new': isHighlighted }"
|
||||
@animationend="isHighlighted = false"
|
||||
>
|
||||
<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: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"
|
||||
@@ -43,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 -->
|
||||
@@ -58,6 +68,14 @@
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown || 300, locale || undefined) }}</span>
|
||||
</template>
|
||||
<template v-else-if="alert.eventExpression">
|
||||
<span>{{ $t("notifications.alert.event-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.eventExpression }}</code>
|
||||
<template v-if="alert.cooldown">
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown, locale || undefined) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ $t("notifications.alert.log-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
|
||||
@@ -65,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>
|
||||
@@ -77,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>
|
||||
@@ -95,11 +115,20 @@
|
||||
import type { Dispatcher, NotificationRule } from "@/types/notifications";
|
||||
import AlertForm from "./AlertForm.vue";
|
||||
|
||||
const { alert, onUpdated } = defineProps<{
|
||||
const { alert, onUpdated, highlight } = defineProps<{
|
||||
alert: NotificationRule;
|
||||
onUpdated?: () => void;
|
||||
highlight?: boolean;
|
||||
}>();
|
||||
|
||||
const isHighlighted = ref(highlight ?? false);
|
||||
watch(
|
||||
() => highlight,
|
||||
(v) => {
|
||||
if (v) isHighlighted.value = true;
|
||||
},
|
||||
);
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
const isDeleting = ref(false);
|
||||
const dispatchers = ref<Dispatcher[]>([]);
|
||||
@@ -147,3 +176,18 @@ async function deleteAlert() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card.highlight-new {
|
||||
animation: highlight-fade 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
from {
|
||||
background-color: oklch(from var(--color-secondary) l c h / 0.25);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
<mdi:chart-line class="mr-1" />
|
||||
{{ $t("notifications.alert-form.metric-alert") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'event'"
|
||||
>
|
||||
<mdi:bell-ring-outline class="mr-1" />
|
||||
{{ $t("notifications.alert-form.event-alert") }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -48,7 +56,7 @@
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="
|
||||
containerExpression.trim() && !containerResult?.error
|
||||
? 'input-primary'
|
||||
@@ -87,6 +95,15 @@
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<MetricAlertFields
|
||||
v-else-if="alertType === 'metric'"
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<EventAlertFields
|
||||
v-else
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
@@ -156,13 +173,21 @@
|
||||
import { useAlertForm } from "@/composable/alertForm";
|
||||
import LogAlertFields from "./LogAlertFields.vue";
|
||||
import MetricAlertFields from "./MetricAlertFields.vue";
|
||||
import EventAlertFields from "./EventAlertFields.vue";
|
||||
import type { NotificationRule } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: { name?: string; containerExpression?: string; logExpression?: string; metricExpression?: string };
|
||||
prefill?: {
|
||||
name?: string;
|
||||
containerExpression?: string;
|
||||
logExpression?: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
dispatcherId?: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
const {
|
||||
@@ -177,7 +202,7 @@ const {
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
initContainerEditor,
|
||||
setupContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
} = useAlertForm(props);
|
||||
@@ -186,11 +211,15 @@ const {
|
||||
const alertNameInput = ref<HTMLInputElement>();
|
||||
const containerEditorRef = ref<HTMLElement>();
|
||||
const destinationDropdown = ref<HTMLDetailsElement>();
|
||||
const fieldsRef = ref<InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields>>();
|
||||
const fieldsRef = ref<
|
||||
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
|
||||
>();
|
||||
useFocus(alertNameInput, { initialValue: true });
|
||||
|
||||
// Alert type
|
||||
const alertType = ref<"log" | "metric">(props.alert?.metricExpression ? "metric" : "log");
|
||||
const alertType = ref<"log" | "metric" | "event">(
|
||||
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
|
||||
);
|
||||
|
||||
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
|
||||
|
||||
@@ -200,15 +229,5 @@ async function save() {
|
||||
}
|
||||
|
||||
// Container editor
|
||||
let containerEditorView: Awaited<ReturnType<typeof initContainerEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (containerEditorRef.value) {
|
||||
containerEditorView = await initContainerEditor(containerEditorRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
containerEditorView?.destroy();
|
||||
});
|
||||
setupContainerEditor(containerEditorRef);
|
||||
</script>
|
||||
|
||||
@@ -10,10 +10,26 @@
|
||||
readonly
|
||||
disabled
|
||||
class="input join-item w-full font-mono"
|
||||
:class="cloudStatusError ? 'input-error' : 'input-success'"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'input-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'input-warning'
|
||||
: 'input-success'
|
||||
"
|
||||
/>
|
||||
<span class="join-item btn pointer-events-none" :class="cloudStatusError ? 'btn-error' : 'btn-success'">
|
||||
<mdi:alert-circle v-if="cloudStatusError" class="text-lg" />
|
||||
<span
|
||||
class="join-item btn pointer-events-none"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'btn-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'btn-warning'
|
||||
: 'btn-success'
|
||||
"
|
||||
>
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else-if="cloudStatusError === 'unavailable'" class="text-lg" />
|
||||
<mdi:check v-else class="text-lg" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -24,9 +40,14 @@
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-checking") }}</span>
|
||||
</div>
|
||||
<div v-else-if="cloudStatusError" class="mt-3">
|
||||
<div class="alert alert-error">
|
||||
<mdi:alert-circle class="text-lg" />
|
||||
<span>{{ $t("notifications.destination-form.cloud-relink") }}</span>
|
||||
<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" />
|
||||
<span>{{
|
||||
cloudStatusError === "auth"
|
||||
? $t("notifications.destination-form.cloud-relink")
|
||||
: $t("notifications.destination-form.cloud-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
|
||||
@@ -91,42 +112,16 @@ const { destination, close } = defineProps<{
|
||||
}>();
|
||||
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=notifications`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
|
||||
// Cloud status
|
||||
interface CloudStatus {
|
||||
user: { email: string; name: string };
|
||||
plan: { name: string; events_per_month: number; retention_days: number };
|
||||
usage: { events_used: number; events_limit: number; period: string };
|
||||
}
|
||||
|
||||
const cloudStatus = ref<CloudStatus | null>(null);
|
||||
const cloudStatusError = ref(false);
|
||||
const isLoadingCloudStatus = ref(false);
|
||||
const { cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudStatus } = useCloudConfig();
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
async function fetchCloudStatus() {
|
||||
isLoadingCloudStatus.value = true;
|
||||
cloudStatusError.value = false;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/status"));
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = true;
|
||||
return;
|
||||
}
|
||||
cloudStatus.value = await res.json();
|
||||
} catch {
|
||||
cloudStatusError.value = true;
|
||||
} finally {
|
||||
isLoadingCloudStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (destination?.prefix) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
|
||||
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
|
||||
<template v-else>{{
|
||||
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
|
||||
}}</template>
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const model = defineModel<number>({ required: true });
|
||||
</script>
|
||||
@@ -27,7 +27,10 @@
|
||||
<li>
|
||||
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
@@ -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?.();
|
||||
|
||||
@@ -2,30 +2,33 @@
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{
|
||||
isEditing
|
||||
? $t("notifications.destination-form.edit-title")
|
||||
: $t("notifications.destination-form.create-title")
|
||||
}}
|
||||
<template v-if="type === 'cloud'">
|
||||
{{ $t("notifications.destination-form.cloud-title") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
isEditing
|
||||
? $t("notifications.destination-form.edit-title")
|
||||
: $t("notifications.destination-form.create-title")
|
||||
}}
|
||||
</template>
|
||||
</h2>
|
||||
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
|
||||
<p class="text-base-content/60">
|
||||
<template v-if="type === 'cloud'">
|
||||
{{ $t("notifications.destination-form.cloud-description") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("notifications.destination-form.description") }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Link Success Alert -->
|
||||
<div v-if="showLinkSuccess" class="alert alert-success">
|
||||
<mdi:check-circle class="text-lg" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
|
||||
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Selection -->
|
||||
<fieldset class="fieldset">
|
||||
<!-- Type Selection (only when creating) -->
|
||||
<fieldset v-if="!isEditing" class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
class="card card-border 20 cursor-pointer transition-colors"
|
||||
class="card card-border cursor-pointer transition-colors"
|
||||
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
@@ -39,26 +42,21 @@
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="card card-border border-base-content/20 transition-colors"
|
||||
class="card card-border cursor-pointer transition-colors"
|
||||
:class="[
|
||||
type === 'cloud' ? 'border-primary bg-primary/10' : '',
|
||||
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
isCloudLinked ? 'cursor-not-allowed opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="type"
|
||||
value="cloud"
|
||||
class="radio radio-primary"
|
||||
:disabled="hasExistingCloudDestination && type !== 'cloud'"
|
||||
/>
|
||||
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" :disabled="isCloudLinked" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{{ $t("notifications.destination-form.cloud-description") }}
|
||||
</div>
|
||||
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
|
||||
<div v-if="isCloudLinked" class="text-success mt-1 text-xs">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.destination-form.cloud-exists") }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,25 +82,17 @@ import type { Dispatcher } from "@/types/notifications";
|
||||
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
|
||||
import CloudDestinationForm from "./CloudDestinationForm.vue";
|
||||
|
||||
const {
|
||||
close,
|
||||
onCreated,
|
||||
destination,
|
||||
existingDispatchers = [],
|
||||
showLinkSuccess = false,
|
||||
} = defineProps<{
|
||||
const { close, onCreated, destination } = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
existingDispatchers?: Dispatcher[];
|
||||
showLinkSuccess?: boolean;
|
||||
}>();
|
||||
|
||||
const isEditing = !!destination;
|
||||
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
|
||||
|
||||
const hasExistingCloudDestination = computed(() => {
|
||||
const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers;
|
||||
return others.some((d) => d.type === "cloud");
|
||||
});
|
||||
const { cloudConfig, fetchCloudConfig } = useCloudConfig();
|
||||
const isCloudLinked = computed(() => !!cloudConfig.value?.linked);
|
||||
|
||||
onMounted(() => fetchCloudConfig());
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="eventError || eventExpression" class="fieldset-label">
|
||||
<span v-if="eventError" class="text-error">{{ eventError }}</span>
|
||||
<span v-else class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.expression-valid") }}
|
||||
</span>
|
||||
</div>
|
||||
<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 (healthStatus, exitCode, signal, etc.)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<CooldownField v-model="cooldown" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createEventHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { eventExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
const eventExpression = ref(props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "");
|
||||
const eventError = ref<string | null>(null);
|
||||
const cooldown = ref(props.alert?.cooldown ?? 10);
|
||||
|
||||
const canSave = computed(() => !!eventExpression.value.trim() && !eventError.value);
|
||||
const typeFields = computed(() => ({
|
||||
eventExpression: eventExpression.value,
|
||||
logExpression: "",
|
||||
metricExpression: "",
|
||||
cooldown: cooldown.value,
|
||||
sampleWindow: 0,
|
||||
}));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !eventExpression.value) {
|
||||
eventError.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
eventExpression: eventExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
eventError.value = data.eventError ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, eventExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'name == "die"',
|
||||
initialValue: props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "",
|
||||
getHints: () => createEventHints(),
|
||||
onChange: (v) => (eventExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -2,10 +2,10 @@
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
|
||||
>
|
||||
<div ref="logEditorRef" class="w-full"></div>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="logError || logExpression" class="fieldset-label">
|
||||
<span v-if="logError" class="text-error">{{ logError }}</span>
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
|
||||
import { createExprEditor, createLogHints } from "@/composable/exprEditor";
|
||||
import { createLogHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -93,22 +93,11 @@ watch(
|
||||
);
|
||||
|
||||
// Editor
|
||||
const logEditorRef = ref<HTMLElement>();
|
||||
let logEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (logEditorRef.value) {
|
||||
logEditorView = await createExprEditor({
|
||||
parent: logEditorRef.value,
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
|
||||
getHints: () => createLogHints(messageKeys.value),
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
logEditorView?.destroy();
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
|
||||
getHints: () => createLogHints(messageKeys.value),
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
|
||||
>
|
||||
<div ref="metricEditorRef" class="w-full"></div>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="metricError || metricExpression" class="fieldset-label">
|
||||
<span v-if="metricError" class="text-error">{{ metricError }}</span>
|
||||
@@ -35,17 +35,11 @@
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
|
||||
<input v-model.number="cooldown" type="range" min="10" max="3600" step="10" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{ $t("notifications.alert-form.cooldown-hint", { duration: formatDuration(cooldown, locale || undefined) }) }}
|
||||
</p>
|
||||
</fieldset>
|
||||
<CooldownField v-model="cooldown" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createExprEditor, createMetricHints } from "@/composable/exprEditor";
|
||||
import { createMetricHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -97,22 +91,11 @@ watch(
|
||||
);
|
||||
|
||||
// Editor
|
||||
const metricEditorRef = ref<HTMLElement>();
|
||||
let metricEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (metricEditorRef.value) {
|
||||
metricEditorView = await createExprEditor({
|
||||
parent: metricEditorRef.value,
|
||||
placeholder: "cpu > 80 || memory > 90",
|
||||
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
|
||||
getHints: () => createMetricHints(),
|
||||
onChange: (v) => (metricExpression.value = v),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
metricEditorView?.destroy();
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: "cpu > 80 || memory > 90",
|
||||
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
|
||||
getHints: () => createMetricHints(),
|
||||
onChange: (v) => (metricExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
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";
|
||||
import WelcomeModal from "./WelcomeModal.vue";
|
||||
|
||||
vi.mock("vue-router");
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "" },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
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() {
|
||||
return mount(WelcomeModal, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("<WelcomeModal /> Create First Alert", () => {
|
||||
const pushSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom's HTMLDialogElement lacks .close()/.showModal() — stub them so WelcomeModal's close() works.
|
||||
if (!HTMLDialogElement.prototype.close) {
|
||||
HTMLDialogElement.prototype.close = function () {};
|
||||
}
|
||||
if (!HTMLDialogElement.prototype.showModal) {
|
||||
HTMLDialogElement.prototype.showModal = function () {};
|
||||
}
|
||||
vi.mocked(useRouter).mockReturnValue({
|
||||
push: pushSpy,
|
||||
} as unknown as ReturnType<typeof useRouter>);
|
||||
pushSpy.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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")) {
|
||||
return new Response(JSON.stringify([{ id: 7, type: "cloud", name: "Dozzle Cloud" }]), { status: 200 });
|
||||
}
|
||||
if (u.includes("/api/notifications/rules")) {
|
||||
return new Response(JSON.stringify({ id: 42 }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const wrapper = mountModal();
|
||||
await openAndAdvance(wrapper);
|
||||
|
||||
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
|
||||
expect(cta).toBeDefined();
|
||||
await cta!.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
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,
|
||||
cooldown: 3600,
|
||||
sampleWindow: 60,
|
||||
containerExpression: "true",
|
||||
eventExpression: "",
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
const fetchMock = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/notifications/dispatchers")) {
|
||||
return new Response(JSON.stringify([{ id: 7, type: "cloud", name: "Dozzle Cloud" }]), { status: 200 });
|
||||
}
|
||||
if (u.includes("/api/notifications/rules")) {
|
||||
return new Response("{}", { status: 500 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const wrapper = mountModal();
|
||||
await openAndAdvance(wrapper);
|
||||
|
||||
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
|
||||
await cta!.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { action: "create-alert" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<dialog ref="modal" class="modal" @close="onClose">
|
||||
<div class="modal-box max-w-md p-8">
|
||||
<!-- Step 1: Feedback -->
|
||||
<template v-if="step === 'step1'">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<mdi:check-circle class="text-success text-4xl" />
|
||||
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.title") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("cloud.welcome.subtitle") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="mb-3 text-sm font-medium">{{ $t("cloud.welcome.question") }}</p>
|
||||
|
||||
<textarea
|
||||
v-model="intent"
|
||||
class="textarea textarea-bordered w-full text-sm"
|
||||
rows="3"
|
||||
:placeholder="$t('cloud.welcome.placeholder')"
|
||||
></textarea>
|
||||
|
||||
<p class="text-base-content/60 mt-3 mb-2 text-xs">{{ $t("cloud.welcome.or-pick") }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in chipOptions"
|
||||
:key="option.value"
|
||||
class="btn btn-sm"
|
||||
:class="selectedOptions.has(option.value) ? 'btn-primary' : 'btn-outline'"
|
||||
@click="toggleOption(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block mt-6" :disabled="submitting" @click="submitFeedback">
|
||||
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
|
||||
{{ $t("cloud.welcome.get-started") }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="submitting" @click="skipFeedback">
|
||||
{{ $t("cloud.welcome.skip") }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 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-2 text-sm">{{ $t("cloud.welcome.step2-body") }}</p>
|
||||
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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" :disabled="creating" @click="close">
|
||||
{{ $t("cloud.welcome.later") }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button></button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const modal = ref<HTMLDialogElement>();
|
||||
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: "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") },
|
||||
];
|
||||
|
||||
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[]>(() => [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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)) {
|
||||
next.delete(value);
|
||||
} else {
|
||||
next.add(value);
|
||||
}
|
||||
selectedOptions.value = next;
|
||||
}
|
||||
|
||||
async function postFeedback(skipped: boolean) {
|
||||
try {
|
||||
await fetch(withBase("/api/cloud/feedback"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: "welcome_modal",
|
||||
intent: skipped ? undefined : intent.value || undefined,
|
||||
selectedOptions: skipped ? undefined : Array.from(selectedOptions.value),
|
||||
skipped,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Feedback failure should not block the user
|
||||
}
|
||||
}
|
||||
|
||||
const onNotificationsPage = computed(() => route.path === "/notifications");
|
||||
|
||||
async function submitFeedback() {
|
||||
submitting.value = true;
|
||||
feedbackSent = true;
|
||||
await postFeedback(false);
|
||||
submitting.value = false;
|
||||
if (onNotificationsPage.value) {
|
||||
await createDefaultAlerts();
|
||||
} else {
|
||||
step.value = "step2";
|
||||
}
|
||||
}
|
||||
|
||||
async function skipFeedback() {
|
||||
submitting.value = true;
|
||||
feedbackSent = true;
|
||||
await postFeedback(true);
|
||||
submitting.value = false;
|
||||
if (onNotificationsPage.value) {
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
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"), { 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");
|
||||
|
||||
// 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");
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
close();
|
||||
router.push({ path: "/notifications" });
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") return;
|
||||
close();
|
||||
showToast(
|
||||
{
|
||||
type: "warning",
|
||||
message: t("notifications.default-alert-failed"),
|
||||
},
|
||||
{ expire: 6000 },
|
||||
);
|
||||
router.push({ path: "/notifications", query: { action: "create-alert" } });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
function onClose() {
|
||||
if (step.value === "step1" && !feedbackSent) {
|
||||
feedbackSent = true;
|
||||
postFeedback(true);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="toast toast-end whitespace-normal max-md:end-auto max-md:m-0 max-md:max-w-full">
|
||||
<div class="toast toast-end max-md:toast-center max-md:toast-bottom whitespace-normal max-md:w-full max-md:px-2">
|
||||
<div
|
||||
class="alert max-w-xl shadow-sm max-md:rounded-none"
|
||||
class="alert max-w-xl shadow-sm max-md:w-full max-md:rounded-lg"
|
||||
v-for="{ toast, options: { timed } } in toasts"
|
||||
:key="toast.id"
|
||||
:class="{
|
||||
@@ -10,14 +10,14 @@
|
||||
'alert-warning': toast.type === 'warning',
|
||||
}"
|
||||
>
|
||||
<carbon:information class="size-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
|
||||
<div v-html="toast.message" class="[&>a]:underline"></div>
|
||||
<carbon:information class="size-5 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-bold max-md:text-base" v-if="toast.title">{{ toast.title }}</h3>
|
||||
<div v-html="toast.message" class="max-md:text-sm [&>a]:underline"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="shrink-0">
|
||||
<TimedButton
|
||||
v-if="timed"
|
||||
class="btn-primary btn-sm"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import type { ContainerJson } from "@/types/Container";
|
||||
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
import { createExprEditor, createContainerHints } from "@/composable/exprEditor";
|
||||
import { createContainerHints } from "@/composable/exprEditor";
|
||||
|
||||
export interface AlertFormOptions {
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: { name?: string; containerExpression?: string; logExpression?: string; metricExpression?: string };
|
||||
prefill?: {
|
||||
name?: string;
|
||||
containerExpression?: string;
|
||||
logExpression?: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
dispatcherId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContainerResult {
|
||||
@@ -19,11 +26,11 @@ export function useAlertForm(options: AlertFormOptions) {
|
||||
const isEditing = computed(() => !!options.alert);
|
||||
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
|
||||
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
|
||||
const dispatcherId = ref(options.alert?.dispatcher?.id ?? 0);
|
||||
const dispatcherId = ref(options.alert?.dispatcher?.id ?? options.prefill?.dispatcherId ?? -1);
|
||||
const isSaving = ref(false);
|
||||
const saveError = ref<string | null>(null);
|
||||
|
||||
// Destinations
|
||||
// Destinations (cloud dispatcher with id=0 is included by the backend when configured)
|
||||
const destinations = ref<Dispatcher[]>([]);
|
||||
onMounted(async () => {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
@@ -48,14 +55,13 @@ export function useAlertForm(options: AlertFormOptions) {
|
||||
() =>
|
||||
alertName.value.trim() &&
|
||||
containerExpression.value.trim() &&
|
||||
dispatcherId.value > 0 &&
|
||||
dispatcherId.value >= 0 &&
|
||||
!containerResult.value?.error &&
|
||||
!isSaving.value,
|
||||
);
|
||||
|
||||
async function initContainerEditor(el: HTMLElement) {
|
||||
return await createExprEditor({
|
||||
parent: el,
|
||||
function setupContainerEditor(editorRef: Ref<HTMLElement | undefined>) {
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'name contains "api"',
|
||||
initialValue: options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "",
|
||||
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
|
||||
@@ -143,7 +149,7 @@ export function useAlertForm(options: AlertFormOptions) {
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
initContainerEditor,
|
||||
setupContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CloudConfig, CloudStatus } from "@/types/notifications";
|
||||
|
||||
// Shared state across all component instances
|
||||
const cloudConfig = ref<CloudConfig | null>(null);
|
||||
const cloudStatus = ref<CloudStatus | null>(null);
|
||||
const cloudStatusError = ref<"auth" | "unavailable" | false>(false);
|
||||
const isLoadingCloudStatus = ref(false);
|
||||
|
||||
async function fetchCloudConfig() {
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"));
|
||||
if (!res.ok) {
|
||||
cloudConfig.value = null;
|
||||
return;
|
||||
}
|
||||
cloudConfig.value = await res.json();
|
||||
} catch {
|
||||
cloudConfig.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
cloudStatusError.value = false;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/status"));
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = res.status === 401 || res.status === 403 ? "auth" : "unavailable";
|
||||
return;
|
||||
}
|
||||
cloudStatus.value = await res.json();
|
||||
} catch {
|
||||
cloudStatusError.value = "unavailable";
|
||||
} finally {
|
||||
isLoadingCloudStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCloudState() {
|
||||
cloudConfig.value = null;
|
||||
cloudStatus.value = null;
|
||||
cloudStatusError.value = false;
|
||||
}
|
||||
|
||||
export function useCloudConfig() {
|
||||
return {
|
||||
cloudConfig,
|
||||
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 };
|
||||
}
|
||||
@@ -2,25 +2,27 @@ import { Container } from "@/models/Container";
|
||||
|
||||
type ContainerActions = "start" | "stop" | "restart";
|
||||
export const useContainerActions = (container: Ref<Container>) => {
|
||||
const { showToast } = useToast();
|
||||
const { showToast, removeToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const actionStates = reactive({
|
||||
stop: false,
|
||||
restart: false,
|
||||
start: false,
|
||||
update: false,
|
||||
});
|
||||
|
||||
async function actionHandler(action: ContainerActions) {
|
||||
const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`;
|
||||
|
||||
const errors = {
|
||||
404: "container not found",
|
||||
500: "unable to complete action",
|
||||
400: "invalid action",
|
||||
404: t("error.container-not-found"),
|
||||
500: t("error.unable-to-complete-action"),
|
||||
400: t("error.invalid-action"),
|
||||
} as Record<number, string>;
|
||||
|
||||
const defaultError = "something went wrong";
|
||||
const toastTitle = "Action Failed";
|
||||
const defaultError = t("error.something-went-wrong");
|
||||
const toastTitle = t("error.action-failed");
|
||||
|
||||
actionStates[action] = true;
|
||||
|
||||
@@ -37,10 +39,103 @@ export const useContainerActions = (container: Ref<Container>) => {
|
||||
actionStates[action] = false;
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const updateUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/update`;
|
||||
const toastId = "container-update";
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
actionStates.update = true;
|
||||
|
||||
showToast(
|
||||
{
|
||||
id: toastId,
|
||||
title: t("toolbar.update"),
|
||||
message: t("toolbar.update-pulling"),
|
||||
type: "info",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(withBase(updateUrl), { method: "POST" });
|
||||
if (!response.ok) {
|
||||
removeToast(toastId);
|
||||
showToast({ type: "error", message: t("error.unable-to-update"), title: t("error.update-failed") });
|
||||
return;
|
||||
}
|
||||
|
||||
reader = response.body?.getReader();
|
||||
if (!reader) return;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const chunk of lines) {
|
||||
const dataLine = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!dataLine) continue;
|
||||
|
||||
const data = JSON.parse(dataLine.slice(6));
|
||||
|
||||
switch (data.status) {
|
||||
case "pulling":
|
||||
break;
|
||||
case "recreating":
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
id: toastId,
|
||||
title: t("toolbar.update"),
|
||||
message: t("toolbar.update-recreating"),
|
||||
type: "info",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
break;
|
||||
case "done":
|
||||
case "up-to-date":
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
title: t("toolbar.update"),
|
||||
message: t(`toolbar.update-${data.status}`),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 3000 },
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
removeToast(toastId);
|
||||
showToast({
|
||||
type: "error",
|
||||
message: data.error || t("error.unknown-error"),
|
||||
title: t("error.update-failed"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
removeToast(toastId);
|
||||
showToast({ type: "error", message: t("error.something-went-wrong"), title: t("error.update-failed") });
|
||||
} finally {
|
||||
reader?.cancel();
|
||||
actionStates.update = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actionStates,
|
||||
start: () => actionHandler("start"),
|
||||
stop: () => actionHandler("stop"),
|
||||
restart: () => actionHandler("restart"),
|
||||
update,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,13 +14,11 @@ import {
|
||||
} from "@/models/LogEntry";
|
||||
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();
|
||||
|
||||
function parseMessage(data: string): LogEntry<LogMessage> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
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`);
|
||||
@@ -31,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`));
|
||||
@@ -55,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>) {
|
||||
@@ -72,10 +82,28 @@ 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 } = useLoggingContext();
|
||||
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
|
||||
let initial = true;
|
||||
|
||||
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 (inverseFilter.value) params.append("inverse", "true");
|
||||
}
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
return params;
|
||||
});
|
||||
|
||||
const allContainers = computed(() => (container ? [container.value] : containers.value));
|
||||
const { loadOlderLogs, loadSkippedLogs } = useLogLoader(messages, allContainers, params, loadingMore);
|
||||
|
||||
function flushNow() {
|
||||
if (messages.value.length + buffer.value.length > config.maxLogs) {
|
||||
if (scrollingPaused.value === true) {
|
||||
@@ -105,7 +133,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
// sort the buffer the very first time because of multiple logs in parallel
|
||||
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (container) {
|
||||
if (container || containers.value.length > 0) {
|
||||
const loadMoreItem = new LoadMoreLogEntry(new Date(), loadOlderLogs);
|
||||
messages.value = [loadMoreItem];
|
||||
}
|
||||
@@ -131,17 +159,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const params = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
return params;
|
||||
});
|
||||
|
||||
const urlWithParams = computed(() => withBase(`${url.value}?${params.value.toString()}`));
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
@@ -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)];
|
||||
@@ -194,51 +228,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
|
||||
watch(urlWithParams, () => connect(), { immediate: true });
|
||||
|
||||
async function loadOlderLogs(entry: LoadMoreLogEntry) {
|
||||
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
|
||||
if (!container) throw new Error("No container");
|
||||
|
||||
const [loader, ...existingLogs] = messages.value;
|
||||
const to = existingLogs[0].date;
|
||||
const lastSeenId = existingLogs[0].id;
|
||||
const last = messages.value[Math.min(messages.value.length - 1, 300)].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const { logs: newLogs, signal } = await loadBetween(container, params, from, to, {
|
||||
min: 100,
|
||||
lastSeenId,
|
||||
});
|
||||
if (newLogs && signal.aborted === false) {
|
||||
messages.value = [loader, ...newLogs, ...existingLogs];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkippedLogs(entry: SkippedLogsEntry) {
|
||||
if (!container) throw new Error("No container");
|
||||
|
||||
const from = entry.firstSkipped.date;
|
||||
const to = entry.lastSkippedLog.date;
|
||||
const lastSeenId = entry.lastSkippedLog.id;
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const { logs, signal } = await loadBetween(container, params, from, to, { lastSeenId });
|
||||
if (logs && signal.aborted === false) {
|
||||
messages.value = messages.value.slice(logs.length).flatMap((log) => (log === entry ? logs : [log]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(() => close());
|
||||
|
||||
watch(messages, () => {
|
||||
@@ -252,54 +241,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadBetween(
|
||||
container: Ref<Container>,
|
||||
params: Ref<URLSearchParams>,
|
||||
from: Date,
|
||||
to: Date,
|
||||
{
|
||||
lastSeenId,
|
||||
startId,
|
||||
min,
|
||||
maxStart,
|
||||
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
|
||||
) {
|
||||
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const urlWithMoreParams = computed(() => {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
if (min) {
|
||||
loadMoreParams.append("min", String(min));
|
||||
}
|
||||
if (maxStart) {
|
||||
loadMoreParams.append("maxStart", String(maxStart));
|
||||
}
|
||||
if (lastSeenId) {
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
}
|
||||
if (startId) {
|
||||
loadMoreParams.append("startId", String(startId));
|
||||
}
|
||||
return withBase(`${url.value}?${loadMoreParams.toString()}`);
|
||||
});
|
||||
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
|
||||
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
|
||||
stopWatcher();
|
||||
|
||||
if (!logs) return { logs: [], signal };
|
||||
|
||||
return {
|
||||
logs: logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line)),
|
||||
signal,
|
||||
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,11 +93,41 @@ 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createAutocomplete(getHints: () => Completion[]) {
|
||||
return (context: any) => {
|
||||
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" },
|
||||
{ label: '"die"', detail: "container died", type: "string" },
|
||||
{ label: '"restart"', detail: "container restarted", type: "string" },
|
||||
{ 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[]): 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,28 @@
|
||||
import { HistoricalContainer } from "@/models/Container";
|
||||
import { LogMessage, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
|
||||
import { ShallowRef } from "vue";
|
||||
import { loadBetween } from "@/composable/eventStreams";
|
||||
import { loadBetween } from "@/composable/loadBetween";
|
||||
|
||||
export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalContainer>): LogStreamSource {
|
||||
const messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
// 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,60 @@
|
||||
import { type Ref } from "vue";
|
||||
import { type LogEvent, type LogMessage, LogEntry, asLogEntry } from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
export function parseMessage(data: string): LogEntry<LogMessage> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export async function loadBetween(
|
||||
container: Container | Ref<Container>,
|
||||
params: Ref<URLSearchParams>,
|
||||
from: Date,
|
||||
to: Date,
|
||||
{
|
||||
lastSeenId,
|
||||
startId,
|
||||
min,
|
||||
maxStart,
|
||||
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
|
||||
) {
|
||||
const c = toValue(container);
|
||||
const url = `/api/hosts/${c.host}/containers/${c.id}/logs`;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
function buildUrl() {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
if (min) {
|
||||
loadMoreParams.append("min", String(min));
|
||||
}
|
||||
if (maxStart) {
|
||||
loadMoreParams.append("maxStart", String(maxStart));
|
||||
}
|
||||
if (lastSeenId) {
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
}
|
||||
if (startId) {
|
||||
loadMoreParams.append("startId", String(startId));
|
||||
}
|
||||
return withBase(`${url}?${loadMoreParams.toString()}`);
|
||||
}
|
||||
|
||||
const fullUrl = buildUrl();
|
||||
const stopWatcher = watchOnce(params, () => abortController.abort("stream changed"));
|
||||
const logs = await (await fetch(fullUrl, { signal })).text();
|
||||
stopWatcher();
|
||||
|
||||
if (!logs) return { logs: [] as LogEntry<LogMessage>[], signal };
|
||||
|
||||
return {
|
||||
logs: logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line)),
|
||||
signal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ShallowRef, type Ref } from "vue";
|
||||
import { type LogMessage, LogEntry, LoadMoreLogEntry, SkippedLogsEntry } from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
import { loadBetween } from "@/composable/loadBetween";
|
||||
|
||||
// Matches the rolling window size used for stats history
|
||||
const LOG_WINDOW_FOR_DELTA = 300;
|
||||
|
||||
export function useLogLoader(
|
||||
messages: ShallowRef<LogEntry<LogMessage>[]>,
|
||||
containers: Ref<Container[]>,
|
||||
params: Ref<URLSearchParams>,
|
||||
loadingMore: Ref<boolean>,
|
||||
) {
|
||||
async function loadOlderLogs(entry: LoadMoreLogEntry) {
|
||||
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
|
||||
if (containers.value.length === 0) return;
|
||||
|
||||
const [loader, ...existingLogs] = messages.value;
|
||||
if (existingLogs.length === 0) return;
|
||||
|
||||
const containerIDs = new Set(containers.value.map((c) => c.id));
|
||||
const earliestByContainer = new Map<string, LogEntry<LogMessage>>();
|
||||
const countByContainer = new Map<string, number>();
|
||||
const nthByContainer = new Map<string, LogEntry<LogMessage>>();
|
||||
for (const log of existingLogs) {
|
||||
const id = log.containerID;
|
||||
if (!id || !containerIDs.has(id)) continue;
|
||||
if (!earliestByContainer.has(id)) {
|
||||
earliestByContainer.set(id, log);
|
||||
}
|
||||
const count = (countByContainer.get(id) ?? 0) + 1;
|
||||
countByContainer.set(id, count);
|
||||
if (count <= LOG_WINDOW_FOR_DELTA) {
|
||||
nthByContainer.set(id, log);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const minPerContainer = Math.ceil(100 / containers.value.length);
|
||||
|
||||
const results = await Promise.all(
|
||||
containers.value.map((c) => {
|
||||
const earliest = earliestByContainer.get(c.id);
|
||||
const to = earliest?.date ?? existingLogs[0].date;
|
||||
const nth = nthByContainer.get(c.id);
|
||||
const delta = to.getTime() - (nth?.date ?? to).getTime();
|
||||
const from = new Date(to.getTime() + (delta !== 0 ? delta : -60_000));
|
||||
return loadBetween(c, params, from, to, {
|
||||
min: minPerContainer,
|
||||
lastSeenId: earliest?.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const allNewLogs = results
|
||||
.filter(({ signal }) => !signal.aborted)
|
||||
.flatMap(({ logs }) => logs)
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (allNewLogs.length > 0) {
|
||||
messages.value = [loader, ...allNewLogs, ...existingLogs];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkippedLogs(entry: SkippedLogsEntry) {
|
||||
if (containers.value.length === 0) return;
|
||||
|
||||
const from = entry.firstSkipped.date;
|
||||
const to = entry.lastSkippedLog.date;
|
||||
const ownerContainerID = entry.lastSkippedLog.containerID;
|
||||
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const results = await Promise.all(
|
||||
containers.value.map((c) => {
|
||||
const lastSeenId = c.id === ownerContainerID ? entry.lastSkippedLog.id : undefined;
|
||||
return loadBetween(c, params, from, to, { lastSeenId });
|
||||
}),
|
||||
);
|
||||
const allLogs = results
|
||||
.filter(({ signal }) => !signal.aborted)
|
||||
.flatMap(({ logs }) => logs)
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (allLogs.length > 0) {
|
||||
const updated = messages.value.flatMap((log) => (log === entry ? allLogs : [log]));
|
||||
messages.value = updated.length > config.maxLogs ? updated.slice(-config.maxLogs) : updated;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { loadOlderLogs, loadSkippedLogs };
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function useProfileStorage<K extends keyof Profile>(
|
||||
}
|
||||
}
|
||||
|
||||
if (config.user) {
|
||||
if (config.user || config.authProvider === "none") {
|
||||
watch(
|
||||
storage,
|
||||
(value) => {
|
||||
@@ -56,6 +56,8 @@ export function useProfileStorage<K extends keyof Profile>(
|
||||
return value;
|
||||
}
|
||||
}),
|
||||
}).catch((e) => {
|
||||
console.error(`Failed to sync ${key} to profile`, e);
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user