mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 12:44:44 +00:00
Compare commits
262 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c3a7826bc | |||
| d82e00e877 | |||
| c4b5c1c193 | |||
| da709df7f4 | |||
| 9099ffce38 | |||
| cdb7855578 | |||
| 536a3a630f | |||
| b8662ff8d5 | |||
| 09d5470053 | |||
| f866724f62 | |||
| ce2b008098 | |||
| 00f2c4f2ef | |||
| a4ee0c82fd | |||
| c2d3e83c55 | |||
| 30347b0491 | |||
| af92c5e0ea | |||
| 3373703114 | |||
| 6246b866d5 | |||
| ad5d3e8e75 | |||
| 044c1db55d | |||
| 9535b80c10 | |||
| 8b367010ce | |||
| 4ba989e52a | |||
| abac919aaa | |||
| 178aeeb77f | |||
| c83fe15bf5 | |||
| 7a1424bdd4 | |||
| 4de9c775ba | |||
| 2e5fb71938 | |||
| 26e4d8810b | |||
| 48fc3d3c74 | |||
| 89f8530620 | |||
| 2fe8e1ab6c | |||
| 9b87cd929c | |||
| 806af34a2d | |||
| 3b1801a10a | |||
| ed49153af7 | |||
| 0c596262f0 | |||
| 3d19870b36 | |||
| ae960bac05 | |||
| 217db5ff54 | |||
| f0b44a2608 | |||
| 9465253baf | |||
| e41a981710 | |||
| 98bbc245dc | |||
| 1fe510dc35 | |||
| 197bf2fa53 | |||
| 5d68080db2 | |||
| 0d613a7c97 | |||
| baf63ca9f1 | |||
| 37c221f89c | |||
| 8e894f0e65 | |||
| f24d4951ad | |||
| d344c5f20e | |||
| 3af15ac43e | |||
| 513e51134b | |||
| 0bb5395d28 | |||
| 58903612fc | |||
| 6452015d8a | |||
| 4b48b73fb9 | |||
| 1898b87b15 | |||
| f9c29e2cbc | |||
| 214b1825a8 | |||
| 19ee1fedc4 | |||
| 2355fdb152 | |||
| 51a2bb5207 | |||
| 34ee472fca | |||
| 9447b1e3c9 | |||
| 6aabe0673c | |||
| a7309d10bb | |||
| 8527c4688d | |||
| 50a60f04f1 | |||
| de0913160d | |||
| 6de5cb0fbc | |||
| bafce9e6c5 | |||
| f1e3a43747 | |||
| 220218d44d | |||
| 5169c211f3 | |||
| 0bab833fa9 | |||
| 895345693c | |||
| a0d60357b8 | |||
| f2e29dadf1 | |||
| 05d6e47d28 | |||
| 882c5a1186 | |||
| 496a894d86 | |||
| e7911dbce6 | |||
| 1484318279 | |||
| 8dce75509c | |||
| 689c399ace | |||
| da62adadc5 | |||
| 3a30c27c9a | |||
| 4edac1174c | |||
| aeffdcb27f | |||
| 3cb3b395a9 | |||
| 4829a5e8c4 | |||
| c2c7041cf7 | |||
| 487a4ad0d4 | |||
| e00af4321f | |||
| ce10266bf2 | |||
| 22975208af | |||
| aed903d57d | |||
| 98a8f4ac81 | |||
| a65f8a2c3a | |||
| 4dd99c209c | |||
| 518e3c8ac9 | |||
| 19c800fc27 | |||
| 34dcf9d068 | |||
| beb95c2f87 | |||
| 3b34578e92 | |||
| 89d9e67862 | |||
| c268fa4e8b | |||
| a089b394e0 | |||
| 555c0b7222 | |||
| 2a36a45fe9 | |||
| 57bcf874fd | |||
| 56339b3959 | |||
| 2e774481c1 | |||
| aed171cb6b | |||
| 4c7f281257 | |||
| 6acb7126f5 | |||
| 9408a9ac45 | |||
| f644d57964 | |||
| 9dfbf7463c | |||
| 1f19785c99 | |||
| ba0206a903 | |||
| 68908e57b7 | |||
| b97e0d28cd | |||
| 1d1f3ed36a | |||
| 38352379ab | |||
| cb602957bc | |||
| 05edd49487 | |||
| b326dca06d | |||
| 219e20fdba | |||
| bc627a725f | |||
| 0c9c09dc4c | |||
| 5810383a29 | |||
| cbda1e753c | |||
| b1f980f49b | |||
| a3310a71f6 | |||
| dafc9f674e | |||
| 16b1aad93f | |||
| b82a0e2fef | |||
| 9510a1f79e | |||
| 8a0166d37d | |||
| 0a2248dfe3 | |||
| a712c8fa34 | |||
| 4b3d2b3224 | |||
| 2864bafa14 | |||
| 8433f89bd2 | |||
| bc1a5fbc8b | |||
| 4b046596b7 | |||
| e09aeb421b | |||
| 5b12a7ba24 | |||
| dc2d892b89 | |||
| a991e66436 | |||
| 44f68cc482 | |||
| dcb5eb4ccc | |||
| 8a2640cd6b | |||
| d37c1395cb | |||
| de23166a22 | |||
| fdffba179d | |||
| b008354f02 | |||
| deaeff73cb | |||
| 88f0a227e7 | |||
| dc993aa816 | |||
| cc8e715cc5 | |||
| 448341c343 | |||
| 53b91e662b | |||
| 011f55c55b | |||
| d7079c36e4 | |||
| 7fb4dcce77 | |||
| 0699680c2f | |||
| e91d643968 | |||
| d0b522116d | |||
| 56ec36e10c | |||
| 588cd604ce | |||
| c49b761f1f | |||
| 22d8e86061 | |||
| a6ca9635d6 | |||
| b5b8650ce6 | |||
| 4d75978133 | |||
| 534f21fa06 | |||
| 2f160a9d8b | |||
| f4dd11d136 | |||
| 81705c8a47 | |||
| 2882961288 | |||
| bac8737161 | |||
| 4d9cc362df | |||
| 885db043ad | |||
| f804e475d1 | |||
| e4e6903ef3 | |||
| ec03e93298 | |||
| 56aa440c92 | |||
| 27989fca52 | |||
| 02c834359b | |||
| 857896e448 | |||
| b781570811 | |||
| ac04e4c149 | |||
| 89d761d389 | |||
| fdb0984e3a | |||
| 9e99e043cd | |||
| 9aaae8ee91 | |||
| 5110456537 | |||
| 6fdd5b788f | |||
| 2b6fb8556f | |||
| af7ed8b424 | |||
| c63dc9dc13 | |||
| 83f488ecef | |||
| 3d2036c97a | |||
| f3d49607a3 | |||
| 9b641a99da | |||
| d09e179445 | |||
| daffeb17f8 | |||
| 3c7df537ec | |||
| 376318bbbf | |||
| c448636641 | |||
| 30323bf3af | |||
| 296ed7945b | |||
| f343e12979 | |||
| b3b01d96a8 | |||
| 783b7f0307 | |||
| cc26b1be81 | |||
| 263ad3f235 | |||
| 9c1b967502 | |||
| 21c775d40b | |||
| 943d0a87a1 | |||
| a08e85cf7b | |||
| 3a2546f919 | |||
| d55e5f1c53 | |||
| 0639320dca | |||
| 293f6a075b | |||
| ef0938d6b8 | |||
| 192d3783af | |||
| 2d7c806390 | |||
| e643099f34 | |||
| 821c3e90f2 | |||
| b72ee29aa3 | |||
| 83ef59cd88 | |||
| 0ca6624238 | |||
| f9aabf138c | |||
| 7ed2c50ba7 | |||
| 993b3fd2f6 | |||
| 9bf6cd8821 | |||
| cb99bcdc09 | |||
| a8e4267c67 | |||
| c0a80c5591 | |||
| 7f38bdaf4b | |||
| c2fce52e1c | |||
| 09a9886423 | |||
| e7cabc8857 | |||
| 7e3d501f0f | |||
| c2dbcd546c | |||
| ec2be9c0f2 | |||
| 32ed13d7d7 | |||
| f5b4cfd55a | |||
| 4c07a7210b | |||
| 34d8ef6913 | |||
| 7fbac3b118 | |||
| 4e59cf3937 | |||
| e02378f93f | |||
| 32b8b02607 | |||
| 721e8e9e36 |
@@ -6,3 +6,4 @@ dist
|
||||
.git
|
||||
e2e
|
||||
docs
|
||||
internal/agent/pb/
|
||||
|
||||
@@ -35,8 +35,14 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
run: |
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test SKIP_ASSET=1
|
||||
run: make test
|
||||
int-test:
|
||||
name: Integration Tests
|
||||
timeout-minutes: 60
|
||||
@@ -58,8 +64,12 @@ jobs:
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v4
|
||||
uses: docker/bake-action@v5
|
||||
with:
|
||||
load: true
|
||||
set: |
|
||||
@@ -78,22 +88,29 @@ jobs:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4fd812986e6c8c2a69e18311145f9371337f27d4 # v3.4.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@0d103c3126aa41d772a8362f6aa67afac040f80c # v3.1.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- 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@v5.1.0
|
||||
uses: docker/build-push-action@v6.3.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -12,21 +12,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
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@4fd812986e6c8c2a69e18311145f9371337f27d4 # v3.4.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@0d103c3126aa41d772a8362f6aa67afac040f80c # v3.1.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- 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@v5.1.0
|
||||
uses: docker/build-push-action@v6.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Sample workflow for building and deploying a VitePress site to GitHub Pages
|
||||
#
|
||||
name: Deploy VitePress site to Pages
|
||||
|
||||
on:
|
||||
@@ -36,7 +34,7 @@ jobs:
|
||||
node-version: latest
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build with VitePress
|
||||
|
||||
@@ -58,12 +58,18 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
run: |
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test SKIP_ASSET=1
|
||||
# - name: Stactic checker
|
||||
# uses: dominikh/staticcheck-action@v1.3.0
|
||||
# with:
|
||||
# install-go: false
|
||||
run: make test
|
||||
- name: Stactic checker
|
||||
uses: dominikh/staticcheck-action@v1.3.1
|
||||
with:
|
||||
install-go: false
|
||||
int-test:
|
||||
name: Integration Tests
|
||||
timeout-minutes: 60
|
||||
@@ -85,8 +91,12 @@ jobs:
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v4
|
||||
uses: docker/bake-action@v5
|
||||
with:
|
||||
load: true
|
||||
set: |
|
||||
|
||||
@@ -12,3 +12,5 @@ coverage.out
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
*.pem
|
||||
*.csr
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-r '\.(go)$' -R 'node_modules' -G '\*\_test.go' -s -- go run -race main.go --level debug agent
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug test",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/main.go",
|
||||
"args": ["test"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+3
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"cSpell.words": ["healthcheck"],
|
||||
"editor.formatOnSave": true
|
||||
"cSpell.words": ["healthcheck", "orderedmap", "stdcopy", "Warnf"],
|
||||
"editor.formatOnSave": true,
|
||||
"i18n-ally.extract.autoDetect": true
|
||||
}
|
||||
|
||||
+16
-8
@@ -1,5 +1,5 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:21-alpine as node
|
||||
FROM --platform=$BUILDPLATFORM node:22.4.1-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -22,9 +22,13 @@ COPY public ./public
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.22.0-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.22.5-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
|
||||
# install gRPC dependencies
|
||||
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
|
||||
&& mkdir /dozzle \
|
||||
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
|
||||
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
|
||||
WORKDIR /dozzle
|
||||
|
||||
@@ -32,25 +36,29 @@ WORKDIR /dozzle
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy assets built with node
|
||||
COPY --from=node /build/dist ./dist
|
||||
|
||||
# Copy all other files
|
||||
COPY internal ./internal
|
||||
COPY main.go ./
|
||||
COPY protos ./protos
|
||||
COPY shared_key.pem shared_cert.pem ./
|
||||
|
||||
# Copy assets built with node
|
||||
COPY --from=node /build/dist ./dist
|
||||
|
||||
# Args
|
||||
ARG TAG=dev
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Generate protos
|
||||
RUN go generate
|
||||
|
||||
# Build binary
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG" -o dozzle
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
FROM scratch
|
||||
|
||||
ENV PATH /bin
|
||||
COPY --from=builder /data /data
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=builder /dozzle/dozzle /dozzle
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
PROTO_DIR := protos
|
||||
GEN_DIR := internal/agent/pb
|
||||
PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto)
|
||||
GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES))
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@rm -rf dist
|
||||
@go clean -i
|
||||
@rm -f shared_key.pem shared_cert.pem
|
||||
@rm -f $(GEN_DIR)/*.pb.go
|
||||
|
||||
.PHONY: dist
|
||||
dist:
|
||||
@@ -14,16 +21,18 @@ fake_assets:
|
||||
@echo "assets build was skipped" > dist/index.html
|
||||
|
||||
.PHONY: test
|
||||
test: fake_assets
|
||||
go test -cover -race ./...
|
||||
test: fake_assets generate
|
||||
go test -cover -race -count 1 -timeout 20s ./...
|
||||
|
||||
.PHONY: build
|
||||
build: dist
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w"
|
||||
build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
|
||||
.PHONY: docker
|
||||
docker:
|
||||
@docker build -t amir20/dozzle .
|
||||
docker: shared_key.pem shared_cert.pem
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle .
|
||||
|
||||
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
|
||||
|
||||
.PHONY: dev
|
||||
dev:
|
||||
@@ -32,3 +41,19 @@ dev:
|
||||
.PHONY: int
|
||||
int:
|
||||
docker compose up --build --force-recreate --exit-code-from playwright
|
||||
|
||||
shared_key.pem:
|
||||
@openssl genpkey -algorithm RSA -out shared_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
|
||||
shared_cert.pem:
|
||||
@openssl req -new -key shared_key.pem -out shared_request.csr -subj "/C=US/ST=California/L=San Francisco/O=Dozzle"
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 365
|
||||
@rm shared_request.csr
|
||||
|
||||
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
|
||||
@go generate
|
||||
|
||||
.PHONY: push
|
||||
push: docker
|
||||
@docker tag amir20/dozzle:latest amir20/dozzle:agent
|
||||
@docker push amir20/dozzle:agent
|
||||
|
||||
@@ -31,7 +31,7 @@ Dozzle is a small container (4 MB compressed). Pull the latest release with:
|
||||
|
||||
The simplest way to use dozzle is to run the docker container. Also, 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:ro -p 8888:8080 amir20/dozzle:latest
|
||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8888:8080 amir20/dozzle:latest
|
||||
|
||||
Dozzle will be available at [http://localhost:8888/](http://localhost:8888/).
|
||||
|
||||
@@ -43,7 +43,7 @@ Here is the Docker Compose file:
|
||||
container_name: dozzle
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
ports:
|
||||
- 8888:8080
|
||||
|
||||
@@ -76,7 +76,7 @@ If it's not enabled please follow [this tutorial](https://github.com/containers/
|
||||
Once you have the podman remote socket you can run Dozzle on podman.
|
||||
|
||||
```
|
||||
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock:ro -d -p 8888:8080 amir20/dozzle:latest
|
||||
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8888:8080 amir20/dozzle:latest
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
+5
-9
@@ -3,6 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const mode = useColorMode();
|
||||
watchEffect(() => {
|
||||
if (smallerScrollbars.value) {
|
||||
document.documentElement.classList.add("has-custom-scrollbars");
|
||||
@@ -10,16 +11,11 @@ watchEffect(() => {
|
||||
document.documentElement.classList.remove("has-custom-scrollbars");
|
||||
}
|
||||
|
||||
switch (lightTheme.value) {
|
||||
case "dark":
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
break;
|
||||
case "light":
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
break;
|
||||
default:
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
let theme = lightTheme.value;
|
||||
if (theme === "auto") {
|
||||
theme = mode.value;
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
});
|
||||
</script>
|
||||
<style lang="postcss">
|
||||
|
||||
Vendored
+40
-19
@@ -27,7 +27,6 @@ declare global {
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const config: typeof import('./stores/config')['default']
|
||||
const containerContext: typeof import('./composable/containerContext')['containerContext']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
@@ -46,6 +45,7 @@ declare global {
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const definePage: typeof import('unplugin-vue-router/runtime')['definePage']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
@@ -58,6 +58,7 @@ declare global {
|
||||
const getDeep: typeof import('./utils/index')['getDeep']
|
||||
const globalShowPopup: typeof import('./composable/popup')['globalShowPopup']
|
||||
const h: typeof import('vue')['h']
|
||||
const hashCode: typeof import('./utils/index')['hashCode']
|
||||
const hourStyle: typeof import('./stores/settings')['hourStyle']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
@@ -71,7 +72,7 @@ declare global {
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const lightTheme: typeof import('./stores/settings')['lightTheme']
|
||||
const locale: typeof import('./stores/settings')['locale']
|
||||
const logSearchContext: typeof import('./composable/logSearchContext')['logSearchContext']
|
||||
const loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
@@ -101,11 +102,11 @@ declare global {
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const persistentVisibleKeys: typeof import('./composable/storage')['persistentVisibleKeys']
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
|
||||
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideContainerContext: typeof import('./composable/containerContext')['provideContainerContext']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const provideLoggingContext: typeof import('./composable/logContext')['provideLoggingContext']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
@@ -188,8 +189,8 @@ declare global {
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useContainerActions: typeof import('./composable/containerActions')['useContainerActions']
|
||||
const useContainerContext: typeof import('./composable/containerContext')['useContainerContext']
|
||||
const useContainerStore: typeof import('./stores/container')['useContainerStore']
|
||||
const useContainerStream: typeof import('./composable/eventStreams')['useContainerStream']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
@@ -229,7 +230,9 @@ declare global {
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useHosts: typeof import('./stores/hosts')['useHosts']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
@@ -239,16 +242,16 @@ declare global {
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useLogSearchContext: typeof import('./composable/logSearchContext')['useLogSearchContext']
|
||||
const useLogStream: typeof import('./composable/eventsource')['useLogStream']
|
||||
const useLoggingContext: typeof import('./composable/logContext')['useLoggingContext']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMergedStream: typeof import('./composable/eventStreams')['useMergedStream']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
@@ -265,6 +268,7 @@ declare global {
|
||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePinnedLogsStore: typeof import('./stores/pinned')['usePinnedLogsStore']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
@@ -288,6 +292,7 @@ declare global {
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSearchFilter: typeof import('./composable/search')['useSearchFilter']
|
||||
const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta']
|
||||
const useServiceStream: typeof import('./composable/eventStreams')['useServiceStream']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory']
|
||||
@@ -295,11 +300,13 @@ declare global {
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStackStream: typeof import('./composable/eventStreams')['useStackStream']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwarmStore: typeof import('./stores/swarm')['useSwarmStore']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
@@ -385,7 +392,6 @@ declare module 'vue' {
|
||||
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
|
||||
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
|
||||
readonly config: UnwrapRef<typeof import('./stores/config')['default']>
|
||||
readonly containerContext: UnwrapRef<typeof import('./composable/containerContext')['containerContext']>
|
||||
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
@@ -416,6 +422,7 @@ declare module 'vue' {
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
@@ -429,6 +436,7 @@ declare module 'vue' {
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
|
||||
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
|
||||
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
@@ -458,11 +466,11 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeys: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeys']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']>
|
||||
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
|
||||
readonly provideLoggingContext: UnwrapRef<typeof import('./composable/logContext')['provideLoggingContext']>
|
||||
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
|
||||
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
@@ -545,8 +553,8 @@ declare module 'vue' {
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
readonly useContainerStream: UnwrapRef<typeof import('./composable/eventStreams')['useContainerStream']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
@@ -586,7 +594,9 @@ declare module 'vue' {
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
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 useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
|
||||
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
@@ -596,16 +606,16 @@ declare module 'vue' {
|
||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']>
|
||||
readonly useLogStream: UnwrapRef<typeof import('./composable/eventsource')['useLogStream']>
|
||||
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
|
||||
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMergedStream: UnwrapRef<typeof import('./composable/eventStreams')['useMergedStream']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
@@ -622,6 +632,7 @@ declare module 'vue' {
|
||||
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
||||
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
|
||||
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
|
||||
readonly usePinnedLogsStore: UnwrapRef<typeof import('./stores/pinned')['usePinnedLogsStore']>
|
||||
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
|
||||
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
|
||||
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
|
||||
@@ -645,6 +656,7 @@ declare module 'vue' {
|
||||
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
|
||||
readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']>
|
||||
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']>
|
||||
readonly useServiceStream: UnwrapRef<typeof import('./composable/eventStreams')['useServiceStream']>
|
||||
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
|
||||
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
|
||||
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
|
||||
@@ -652,11 +664,13 @@ declare module 'vue' {
|
||||
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
|
||||
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
|
||||
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
|
||||
readonly useStackStream: UnwrapRef<typeof import('./composable/eventStreams')['useStackStream']>
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
readonly useSwarmStore: UnwrapRef<typeof import('./stores/swarm')['useSwarmStore']>
|
||||
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
|
||||
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
|
||||
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
|
||||
@@ -735,7 +749,6 @@ declare module '@vue/runtime-core' {
|
||||
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
|
||||
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
|
||||
readonly config: UnwrapRef<typeof import('./stores/config')['default']>
|
||||
readonly containerContext: UnwrapRef<typeof import('./composable/containerContext')['containerContext']>
|
||||
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
@@ -766,6 +779,7 @@ declare module '@vue/runtime-core' {
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
@@ -779,6 +793,7 @@ declare module '@vue/runtime-core' {
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
|
||||
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
|
||||
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
@@ -808,11 +823,11 @@ declare module '@vue/runtime-core' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeys: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeys']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly provideContainerContext: UnwrapRef<typeof import('./composable/containerContext')['provideContainerContext']>
|
||||
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
|
||||
readonly provideLoggingContext: UnwrapRef<typeof import('./composable/logContext')['provideLoggingContext']>
|
||||
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
|
||||
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
@@ -895,8 +910,8 @@ declare module '@vue/runtime-core' {
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
readonly useContainerContext: UnwrapRef<typeof import('./composable/containerContext')['useContainerContext']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
readonly useContainerStream: UnwrapRef<typeof import('./composable/eventStreams')['useContainerStream']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
@@ -936,7 +951,9 @@ declare module '@vue/runtime-core' {
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
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 useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
|
||||
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
@@ -946,16 +963,16 @@ declare module '@vue/runtime-core' {
|
||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']>
|
||||
readonly useLogStream: UnwrapRef<typeof import('./composable/eventsource')['useLogStream']>
|
||||
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
|
||||
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMergedStream: UnwrapRef<typeof import('./composable/eventStreams')['useMergedStream']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
@@ -972,6 +989,7 @@ declare module '@vue/runtime-core' {
|
||||
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
||||
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
|
||||
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
|
||||
readonly usePinnedLogsStore: UnwrapRef<typeof import('./stores/pinned')['usePinnedLogsStore']>
|
||||
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
|
||||
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
|
||||
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
|
||||
@@ -995,6 +1013,7 @@ declare module '@vue/runtime-core' {
|
||||
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
|
||||
readonly useSearchFilter: UnwrapRef<typeof import('./composable/search')['useSearchFilter']>
|
||||
readonly useSeoMeta: UnwrapRef<typeof import('@vueuse/head')['useSeoMeta']>
|
||||
readonly useServiceStream: UnwrapRef<typeof import('./composable/eventStreams')['useServiceStream']>
|
||||
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
|
||||
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
|
||||
readonly useSimpleRefHistory: UnwrapRef<typeof import('./utils/index')['useSimpleRefHistory']>
|
||||
@@ -1002,11 +1021,13 @@ declare module '@vue/runtime-core' {
|
||||
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
|
||||
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
|
||||
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
|
||||
readonly useStackStream: UnwrapRef<typeof import('./composable/eventStreams')['useStackStream']>
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
readonly useSwarmStore: UnwrapRef<typeof import('./stores/swarm')['useSwarmStore']>
|
||||
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
|
||||
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
|
||||
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
|
||||
|
||||
Vendored
+29
-12
@@ -1,13 +1,12 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BarChart: typeof import('./components/BarChart.vue')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
|
||||
@@ -25,33 +24,37 @@ declare module 'vue' {
|
||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerHealth: typeof import('./components/LogViewer/ContainerHealth.vue')['default']
|
||||
ContainerLogViewer: typeof import('./components/LogViewer/ContainerLogViewer.vue')['default']
|
||||
ContainerPopup: typeof import('./components/LogViewer/ContainerPopup.vue')['default']
|
||||
ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default']
|
||||
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
|
||||
ContainerEventLogItem: typeof import('./components/LogViewer/ContainerEventLogItem.vue')['default']
|
||||
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
|
||||
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
|
||||
ContainerName: typeof import('./components/LogViewer/ContainerName.vue')['default']
|
||||
ContainerPopup: typeof import('./components/ContainerPopup.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
|
||||
DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default']
|
||||
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
HostList: typeof import('./components/HostList.vue')['default']
|
||||
HostMenu: typeof import('./components/HostMenu.vue')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
|
||||
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
|
||||
Links: typeof import('./components/Links.vue')['default']
|
||||
LogActionsToolbar: typeof import('./components/LogViewer/LogActionsToolbar.vue')['default']
|
||||
LogContainer: typeof import('./components/LogViewer/LogContainer.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default']
|
||||
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
|
||||
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
|
||||
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
|
||||
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||
LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default']
|
||||
'MaterialSymbols:collapseAllRounded': typeof import('~icons/material-symbols/collapse-all-rounded')['default']
|
||||
'MaterialSymbols:expandAllRounded': typeof import('~icons/material-symbols/expand-all-rounded')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:check': typeof import('~icons/mdi/check')['default']
|
||||
@@ -64,13 +67,22 @@ declare module 'vue' {
|
||||
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
|
||||
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
|
||||
MultiContainerLog: typeof import('./components/MultiContainerViewer/MultiContainerLog.vue')['default']
|
||||
MultiContainerStat: typeof import('./components/LogViewer/MultiContainerStat.vue')['default']
|
||||
'Octicon:container24': typeof import('~icons/octicon/container24')['default']
|
||||
'Octicon:download24': typeof import('~icons/octicon/download24')['default']
|
||||
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
|
||||
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: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:cpu': typeof import('~icons/ph/cpu')['default']
|
||||
'Ph:memory': typeof import('~icons/ph/memory')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
Releases: typeof import('./components/Releases.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
@@ -78,15 +90,20 @@ declare module 'vue' {
|
||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
|
||||
Search: typeof import('./components/Search.vue')['default']
|
||||
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
SidePanel: typeof import('./components/SidePanel.vue')['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']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
|
||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="bar h-7 origin-left rounded-br rounded-tr bg-primary transition-transform"></div>
|
||||
<div class="absolute inset-0 flex flex-col justify-center px-2 text-sm">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { value } = defineProps<{ value: number }>();
|
||||
|
||||
const minValue = computed(() => Math.min(value, 1));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
transform: scaleX(v-bind(minValue));
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<span class="font-light capitalize"> RUNNING </span>
|
||||
<span class="font-semibold">
|
||||
<distance-time :date="container.created" strict :suffix="false"></distance-time>
|
||||
<DistanceTime :date="container.created" strict :suffix="false" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1,14 +1,49 @@
|
||||
!
|
||||
<template>
|
||||
<div class="text-right">
|
||||
Show per page
|
||||
<dropdown-menu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
v-if="containers.length > 0"
|
||||
/>
|
||||
<div class="flex flex-row">
|
||||
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
|
||||
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
|
||||
<input
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab !rounded"
|
||||
aria-label="Show All"
|
||||
v-model="selectedHost"
|
||||
:value="null"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab !rounded"
|
||||
:aria-label="host.name"
|
||||
v-for="host in hosts"
|
||||
:value="host.id"
|
||||
:key="host.id"
|
||||
v-model="selectedHost"
|
||||
/>
|
||||
</div>
|
||||
<dropdown-menu
|
||||
class="btn-sm"
|
||||
v-model="selectedHost"
|
||||
:options="[
|
||||
{ label: 'Show All', value: null },
|
||||
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
|
||||
]"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
|
||||
{{ $t("label.per-page") }}
|
||||
<dropdown-menu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-lg bg-base">
|
||||
<thead>
|
||||
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
|
||||
@@ -31,7 +66,7 @@
|
||||
<tbody>
|
||||
<tr v-for="container in paginated" :key="container.id">
|
||||
<td v-if="isVisible('name')">
|
||||
<router-link :to="{ name: 'container-id', params: { id: container.id } }" :title="container.name">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
@@ -41,28 +76,34 @@
|
||||
<distance-time :date="container.created" strict :suffix="false"></distance-time>
|
||||
</td>
|
||||
<td v-if="isVisible('cpu')">
|
||||
<bar-chart :value="container.movingAverage.cpu / 100">
|
||||
{{ (container.movingAverage.cpu / 100).toLocaleString(undefined, { style: "percent" }) }}
|
||||
</bar-chart>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<progress
|
||||
class="progress progress-primary"
|
||||
:value="Math.min(container.movingAverage.cpu, 100)"
|
||||
:max="100"
|
||||
></progress>
|
||||
<span class="text-sm">{{ container.movingAverage.cpu.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="isVisible('mem')">
|
||||
<bar-chart :value="container.movingAverage.memory / 100">
|
||||
{{ (container.movingAverage.memory / 100).toLocaleString(undefined, { style: "percent" }) }}
|
||||
</bar-chart>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress>
|
||||
<span class="text-sm">{{ container.movingAverage.memory.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-4 text-center">
|
||||
<nav class="join" v-if="isPaginated">
|
||||
<button
|
||||
<input
|
||||
class="btn btn-square join-item"
|
||||
type="radio"
|
||||
v-model="currentPage"
|
||||
:aria-label="`${i}`"
|
||||
:value="i"
|
||||
v-for="i in totalPages"
|
||||
class="btn join-item"
|
||||
:class="{ 'btn-primary': i === currentPage }"
|
||||
@click="currentPage = i"
|
||||
>
|
||||
{{ i }}
|
||||
</button>
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,6 +112,9 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import { toRefs } from "@vueuse/core";
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const selectedHost = ref(null);
|
||||
|
||||
const fields = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
@@ -117,13 +161,13 @@ const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CO
|
||||
direction: -1,
|
||||
});
|
||||
const { column: sortField, direction } = toRefs(storage);
|
||||
const counter = useInterval(10000);
|
||||
const filteredContainers = computed(() =>
|
||||
containers.filter((c) => selectedHost.value === null || c.host === selectedHost.value),
|
||||
);
|
||||
const sortedContainers = computedWithControl(
|
||||
() => [containers.length, sortField.value, direction.value],
|
||||
() => {
|
||||
return containers.sort((a, b) => {
|
||||
return fields[sortField.value].sortFunc(a, b);
|
||||
});
|
||||
},
|
||||
() => [filteredContainers.value.length, sortField.value, direction.value, counter.value],
|
||||
() => filteredContainers.value.sort((a, b) => fields[sortField.value].sortFunc(a, b)),
|
||||
);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage.value));
|
||||
|
||||
+22
-9
@@ -8,18 +8,16 @@
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<key-shortcut char="k" :modifiers="['shift', 'meta']"></key-shortcut>
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="`${base}/api/logs/download/${container.host}/${container.id}`" download>
|
||||
<octicon:download-24 /> {{ $t("toolbar.download") }}
|
||||
</a>
|
||||
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
<key-shortcut char="f"></key-shortcut>
|
||||
<KeyShortcut char="f" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="line"></li>
|
||||
@@ -103,15 +101,30 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { base, enableActions } = config;
|
||||
const { enableActions } = config;
|
||||
|
||||
const clear = defineEmit();
|
||||
|
||||
const { container, streamConfig } = useContainerContext();
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
// container context is provided in the parent component: <LogContainer>
|
||||
const { actionStates, start, stop, restart } = useContainerActions();
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
|
||||
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
|
||||
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
|
||||
);
|
||||
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/hosts/${container.host}/containers/${container.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const disableRestart = computed(() => {
|
||||
return actionStates.stop || actionStates.start || actionStates.restart;
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<ContainerTitle :container="container" />
|
||||
<MultiContainerStat class="ml-auto" :containers="[container]" />
|
||||
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" class="mobile-hidden" :container="container" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
@loading-more="setLoading($event)"
|
||||
:stream-source="useContainerStream"
|
||||
:entity="container"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="false"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const {
|
||||
id,
|
||||
showTitle = false,
|
||||
scrollable = false,
|
||||
closable = false,
|
||||
} = defineProps<{
|
||||
id: string;
|
||||
showTitle?: boolean;
|
||||
scrollable?: boolean;
|
||||
closable?: boolean;
|
||||
}>();
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer($$(id));
|
||||
const visibleKeys = persistentVisibleKeysForContainer(container);
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(toRef(() => [container.value]));
|
||||
</script>
|
||||
+10
-8
@@ -14,25 +14,27 @@
|
||||
class="mobile-hidden max-w-[1.5em] truncate transition-[max-width] hover:max-w-[400px]"
|
||||
v-if="container.isSwarm"
|
||||
>
|
||||
{{ container.swarmId }}
|
||||
.{{ container.swarmId }}
|
||||
</div>
|
||||
</div>
|
||||
<container-health :health="container.health" v-if="container.health"></container-health>
|
||||
<tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
</tag>
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { container } = useContainerContext();
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.value.name),
|
||||
get: () => pinnedContainers.value.has(container.name),
|
||||
set: (value) => {
|
||||
if (value) {
|
||||
pinnedContainers.value.add(container.value.name);
|
||||
pinnedContainers.value.add(container.name);
|
||||
} else {
|
||||
pinnedContainers.value.delete(container.value.name);
|
||||
pinnedContainers.value.delete(container.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -33,7 +33,7 @@ function createFuzzySearchModal() {
|
||||
containers: [
|
||||
new Container("123", new Date(), "image", "test", "command", "host", {}, "status", "running", []),
|
||||
new Container("345", new Date(), "image", "foo bar", "command", "host", {}, "status", "running", []),
|
||||
new Container("567", new Date(), "image", "baz", "command", "host", {}, "status", "exited", []),
|
||||
new Container("567", new Date(), "image", "baz", "command", "host", {}, "status", "running", []),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -56,9 +56,10 @@ describe("<FuzzySearchModal />", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter().push).mockReset();
|
||||
});
|
||||
test("shows all", async () => {
|
||||
|
||||
test("shows none initially", async () => {
|
||||
const wrapper = createFuzzySearchModal();
|
||||
expect(wrapper.findAll("li").length).toBe(3);
|
||||
expect(wrapper.findAll("li").length).toBe(0);
|
||||
});
|
||||
|
||||
test("search for foo", async () => {
|
||||
@@ -74,6 +75,6 @@ describe("<FuzzySearchModal />", () => {
|
||||
const wrapper = createFuzzySearchModal();
|
||||
await wrapper.find("input").setValue("baz");
|
||||
await wrapper.find("input").trigger("keydown.enter");
|
||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "container-id", params: { id: "567" } });
|
||||
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,97 +15,141 @@
|
||||
/>
|
||||
<mdi:keyboard-esc class="flex" />
|
||||
</div>
|
||||
<ul tabindex="0" class="menu dropdown-content !relative mt-2 w-full rounded-box bg-base-lighter p-2">
|
||||
<li v-for="(result, index) in data">
|
||||
<a
|
||||
class="grid auto-cols-max grid-cols-[min-content,auto] gap-2 py-4"
|
||||
@click.prevent="selected(result.item)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
:class="index === selectedIndex ? 'focus' : ''"
|
||||
>
|
||||
<div :class="{ 'text-primary': result.item.state === 'running' }">
|
||||
<octicon:container-24 />
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<template v-if="config.hosts.length > 1">
|
||||
<span class="font-light">{{ result.item.host }}</span> /
|
||||
</template>
|
||||
<span data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<distance-time :date="result.item.created" class="text-xs font-light" />
|
||||
<div
|
||||
class="dropdown-content !relative mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent bg-base-lighter px-2"
|
||||
v-if="results.length"
|
||||
>
|
||||
<ul tabindex="0" class="menu">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="hover:text-secondary"
|
||||
class="grid auto-cols-max grid-cols-[min-content,auto] gap-2 py-4"
|
||||
@click.prevent="selected(result.item)"
|
||||
:class="index === selectedIndex ? 'focus' : ''"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
|
||||
<cil:columns v-else />
|
||||
<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>
|
||||
|
||||
<DistanceTime :date="result.item.created" class="text-xs font-light" />
|
||||
<a
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="hover:text-secondary"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
|
||||
<cil:columns v-else />
|
||||
</a>
|
||||
</a>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ContainerState } from "@/types/Container";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
import { type FuseResultMatch } from "fuse.js";
|
||||
|
||||
const { maxResults = 5 } = defineProps<{
|
||||
maxResults?: number;
|
||||
}>();
|
||||
import { type FuseResult } from "fuse.js";
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const query = ref("");
|
||||
const input = ref<HTMLInputElement>();
|
||||
const listItems = ref<HTMLInputElement[]>();
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useContainerStore();
|
||||
const { containers } = storeToRefs(store);
|
||||
const containerStore = useContainerStore();
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
|
||||
const swarmStore = useSwarmStore();
|
||||
const { stacks, services } = storeToRefs(swarmStore);
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
created: Date;
|
||||
name: string;
|
||||
state?: ContainerState;
|
||||
host?: string;
|
||||
type: "container" | "service" | "stack";
|
||||
};
|
||||
|
||||
const list = computed(() => {
|
||||
return containers.value.map(({ id, created, name, state, labels, hostLabel: host }) => {
|
||||
return {
|
||||
id,
|
||||
created,
|
||||
name,
|
||||
state,
|
||||
host,
|
||||
labels: Object.entries(labels).map(([_, value]) => value),
|
||||
};
|
||||
});
|
||||
const items: Item[] = [];
|
||||
|
||||
for (const container of visibleContainers.value) {
|
||||
items.push({
|
||||
id: container.id,
|
||||
created: container.created,
|
||||
name: container.name,
|
||||
state: container.state,
|
||||
host: container.hostLabel,
|
||||
type: "container",
|
||||
});
|
||||
}
|
||||
|
||||
for (const service of services.value) {
|
||||
items.push({
|
||||
id: service.name,
|
||||
created: service.updatedAt,
|
||||
name: service.name,
|
||||
state: "running",
|
||||
type: "service",
|
||||
});
|
||||
}
|
||||
|
||||
for (const stack of stacks.value) {
|
||||
items.push({
|
||||
id: stack.name,
|
||||
created: stack.updatedAt,
|
||||
name: stack.name,
|
||||
state: "running",
|
||||
type: "stack",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const { results } = useFuse(query, list, {
|
||||
fuseOptions: {
|
||||
keys: ["name", "host", "labels"],
|
||||
keys: ["name", "host"],
|
||||
includeScore: true,
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
includeMatches: true,
|
||||
},
|
||||
resultLimit: 10,
|
||||
matchAllWhenSearchEmpty: true,
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
return results.value
|
||||
.toSorted((a, b) => {
|
||||
if (a.score === b.score) {
|
||||
if (a.item.state === b.item.state) {
|
||||
return b.item.created - a.item.created;
|
||||
} else if (a.item.state === "running" && b.item.state !== "running") {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||
if (a.score === b.score) {
|
||||
if (a.item.state === b.item.state) {
|
||||
return b.item.created.getTime() - a.item.created.getTime();
|
||||
} else if (a.item.state === "running" && b.item.state !== "running") {
|
||||
return -1;
|
||||
} else {
|
||||
return a.score - b.score;
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
} else {
|
||||
return (a.score ?? 0) - (b.score ?? 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
watch(query, (data) => {
|
||||
@@ -114,24 +158,35 @@ watch(query, (data) => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => input.value?.focus());
|
||||
watch(selectedIndex, () => {
|
||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
||||
});
|
||||
|
||||
function selected({ id }: { id: string }) {
|
||||
router.push({ name: "container-id", params: { id } });
|
||||
useFocus(input, { initialValue: true });
|
||||
|
||||
function selected(item: Item) {
|
||||
if (item.type === "container") {
|
||||
router.push({ name: "/container/[id]", params: { id: item.id } });
|
||||
} else if (item.type === "service") {
|
||||
router.push({ name: "/service/[name]", params: { name: item.id } });
|
||||
} else if (item.type === "stack") {
|
||||
router.push({ name: "/stack/[name]", params: { name: item.id } });
|
||||
}
|
||||
close();
|
||||
}
|
||||
function addColumn(container: { id: string }) {
|
||||
store.appendActiveContainer(container);
|
||||
pinnedStore.pinContainer(container);
|
||||
close();
|
||||
}
|
||||
|
||||
function matchedName({ item, matches = [] }: { item: { name: string }; matches?: FuseResultMatch[] }) {
|
||||
function matchedName({ item, matches = [] }: FuseResult<Item>) {
|
||||
const matched = matches.find((match) => match.key === "name");
|
||||
if (matched) {
|
||||
const { indices } = matched;
|
||||
const result = [];
|
||||
let lastIndex = 0;
|
||||
for (const [start, end] of indices) {
|
||||
if (lastIndex > start) continue;
|
||||
result.push(item.name.slice(lastIndex, start));
|
||||
result.push(`<mark>${item.name.slice(start, end + 1)}</mark>`);
|
||||
lastIndex = end + 1;
|
||||
@@ -148,8 +203,4 @@ function matchedName({ item, matches = [] }: { item: { name: string }; matches?:
|
||||
:deep(mark) {
|
||||
@apply bg-transparent text-inherit underline underline-offset-2;
|
||||
}
|
||||
|
||||
.menu a {
|
||||
@apply transition-none duration-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="group.containers.length && ready">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ group.containers.length }} containers</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="group.containers" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
@loading-more="setLoading($event)"
|
||||
:stream-source="useGroupedStream"
|
||||
:entity="group"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { GroupedContainers } from "@/models/Container";
|
||||
|
||||
const { name, scrollable = false } = defineProps<{
|
||||
name: string;
|
||||
scrollable?: boolean;
|
||||
}>();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const swarmStore = useSwarmStore();
|
||||
const { customGroups } = storeToRefs(swarmStore);
|
||||
|
||||
const group = computed(() => customGroups.value.find((g) => g.name === name) ?? new GroupedContainers("", []));
|
||||
|
||||
const visibleKeys = ref<string[][]>([]);
|
||||
provideLoggingContext(toRef(() => group.value.containers));
|
||||
</script>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<ul class="grid gap-4 md:grid-cols-[repeat(auto-fill,minmax(480px,1fr))]">
|
||||
<li v-for="host in hosts" class="card bg-base-lighter">
|
||||
<div class="card-body grid auto-cols-auto grid-flow-col justify-between gap-4">
|
||||
<div class="overflow-hidden">
|
||||
<div class="truncate text-xl font-semibold">
|
||||
{{ host.name }} <span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</div>
|
||||
<ul class="flex flex-row gap-2 text-sm md:gap-4">
|
||||
<li><ph:cpu class="inline-block" /> {{ host.nCPU }} <span class="mobile-hidden">CPUs</span></li>
|
||||
<li>
|
||||
<ph:memory class="inline-block" /> {{ formatBytes(host.memTotal) }}
|
||||
<span class="mobile-hidden">total</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="text-sm">
|
||||
<octicon:container-24 class="inline-block" /> {{ $t("label.container", hostContainers[host.id]?.length) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-4 md:gap-8" v-if="weightedStats[host.id]">
|
||||
<div
|
||||
class="radial-progress text-sm text-primary [--size:4rem] [--thickness:0.25em] md:text-[1rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalCPU / (host.nCPU * 100)) * 100)}; `"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ weightedStats[host.id].weighted.totalCPU.toFixed(0) }}%
|
||||
</div>
|
||||
<div
|
||||
class="radial-progress text-sm text-primary [--size:4rem] [--thickness:0.25em] md:text-[1rem] md:[--size:5rem]"
|
||||
:style="`--value: ${(weightedStats[host.id].weighted.totalMem / host.memTotal) * 100};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ formatBytes(weightedStats[host.id].weighted.totalMem, 1) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((container) => container.state === "running"));
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const hostContainers = computed(() => {
|
||||
const results: Record<string, Container[]> = {};
|
||||
for (const container of runningContainers.value) {
|
||||
if (!results[container.host]) {
|
||||
results[container.host] = [];
|
||||
}
|
||||
results[container.host].push(container);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
};
|
||||
const weightedStats: Record<string, { mostRecent: TotalStat; weighted: TotalStat }> = {};
|
||||
const initWeightedStats = () => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const mostRecent = ref<TotalStat>({ totalCPU: 0, totalMem: 0 });
|
||||
for (const container of containers) {
|
||||
mostRecent.value.totalCPU += container.stat.cpu;
|
||||
mostRecent.value.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
weightedStats[host] = reactive({ mostRecent, weighted: useExponentialMovingAverage(mostRecent) });
|
||||
}
|
||||
};
|
||||
|
||||
watchOnce(hostContainers, initWeightedStats);
|
||||
initWeightedStats();
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const stat = { totalCPU: 0, totalMem: 0 };
|
||||
for (const container of containers) {
|
||||
stat.totalCPU += container.stat.cpu;
|
||||
stat.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
weightedStats[host].mostRecent = stat;
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<a @click.prevent="setHost(null)" class="link-primary">{{ $t("label.hosts") }}</a>
|
||||
</li>
|
||||
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
|
||||
{{ hosts[sessionHost].name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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="{ 'pointer-events-none text-base-content/50': !host.available }">
|
||||
<ph:computer-tower />
|
||||
{{ host.name }}
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
<ul class="containers menu p-0 [&_li.menu-title]:px-0">
|
||||
<li v-for="{ label, containers, icon } in menuItems" :key="label">
|
||||
<!-- @vue-ignore -->
|
||||
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
|
||||
<summary class="font-light text-base-content/80">
|
||||
<component :is="icon" />
|
||||
{{ label.startsWith("label.") ? $t(label) : label }}
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
name: '/merged/[name]',
|
||||
query: { id: containers.map(({ id }) => id) },
|
||||
params: { name: label.replace('label.', '') },
|
||||
}"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-containers')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="item in containers" :class="item.state" :key="item.id">
|
||||
<Popup>
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: item.id } }"
|
||||
active-class="active-primary"
|
||||
@click.alt.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
:title="item.name"
|
||||
class="group auto-cols-[auto_max-content_max-content]"
|
||||
>
|
||||
<div class="truncate">
|
||||
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">.{{ item.swarmId }}</span>
|
||||
</div>
|
||||
<ContainerHealth :health="item.health" />
|
||||
<span
|
||||
class="hidden hover:text-secondary group-hover:inline-block"
|
||||
@click.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
v-show="!pinnedStore.isPinned(item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
>
|
||||
<cil:columns />
|
||||
</span>
|
||||
</router-link>
|
||||
<template #content>
|
||||
<ContainerPopup :container="item" />
|
||||
</template>
|
||||
</Popup>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</SlideTransition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
|
||||
// @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();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const setHost = (host: string | null) => (sessionHost.value = host);
|
||||
|
||||
const collapsedGroups = useProfileStorage("collapsedGroups", new Set<string>());
|
||||
const updateCollapsedGroups = (event: Event, label: string) => {
|
||||
const details = event.target as HTMLDetailsElement;
|
||||
if (details.open) {
|
||||
collapsedGroups.value.delete(label);
|
||||
} else {
|
||||
collapsedGroups.value.add(label);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedPinnedContainers = debouncedRef(pinnedContainers, 200);
|
||||
const sortedContainers = computed(() =>
|
||||
visibleContainers.value.filter((c) => c.host === sessionHost.value).sort(sorter),
|
||||
);
|
||||
|
||||
const sorter = (a: Container, b: Container) => {
|
||||
if (a.state === "running" && b.state !== "running") {
|
||||
return -1;
|
||||
} else if (a.state !== "running" && b.state === "running") {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const namespaced: Record<string, Container[]> = {};
|
||||
const pinned = [];
|
||||
const singular = [];
|
||||
|
||||
for (const item of sortedContainers.value) {
|
||||
const namespace = item.namespace;
|
||||
if (debouncedPinnedContainers.value.has(item.name)) {
|
||||
pinned.push(item);
|
||||
} else if (namespace) {
|
||||
namespaced[namespace] ||= [];
|
||||
namespaced[namespace].push(item);
|
||||
} else {
|
||||
singular.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const items = [];
|
||||
if (pinned.length) {
|
||||
items.push({ label: "label.pinned", containers: pinned, icon: Pin });
|
||||
}
|
||||
for (const [label, containers] of Object.entries(namespaced).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (containers.length > 1) {
|
||||
items.push({ label, containers, icon: Stack });
|
||||
} else {
|
||||
singular.push(containers[0]);
|
||||
}
|
||||
}
|
||||
|
||||
singular.sort(sorter);
|
||||
|
||||
if (singular.length) {
|
||||
items.push({
|
||||
label: showAllContainers.value ? "label.all-containers" : "label.running-containers",
|
||||
containers: singular,
|
||||
icon: Containers,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const route = useRoute("/container/[id]");
|
||||
|
||||
watchEffect(() => {
|
||||
if (route.name === "/container/[id]") {
|
||||
const container = containerStore.findContainerById(route.params.id);
|
||||
if (container) {
|
||||
setHost(container.host);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.menu {
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
|
||||
li.exited {
|
||||
@apply opacity-50;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
const observer = new IntersectionObserver(async (entries) => {
|
||||
// console.log(entries, entries[0].intersectionRatio);
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (onLoadMore && enabled) {
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<dropdown class="dropdown-end" @closed="latestTag = latest?.tag ?? config.version">
|
||||
<Dropdown class="dropdown-end" @closed="latestTag = latest?.tag ?? config.version">
|
||||
<template #trigger>
|
||||
<mdi:announcement class="size-6 -rotate-12" />
|
||||
<span
|
||||
@@ -10,13 +10,13 @@
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-72">
|
||||
<releases />
|
||||
<Releases />
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
</Dropdown>
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'settings' }"
|
||||
:to="{ name: '/settings' }"
|
||||
:aria-label="$t('title.settings')"
|
||||
data-testid="settings"
|
||||
class="btn btn-circle btn-sm"
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<div class="relative flex w-full gap-x-2">
|
||||
<div class="group/item relative flex w-full gap-x-2" @click="expandToggle()">
|
||||
<label class="swap swap-rotate invisible absolute -left-4 top-0.5 size-4 group-hover/item:visible">
|
||||
<input type="checkbox" v-model="expanded" @click="expandToggle()" />
|
||||
<material-symbols:expand-all-rounded class="swap-off text-secondary" />
|
||||
<material-symbols:collapse-all-rounded class="swap-on text-secondary" />
|
||||
</label>
|
||||
<div v-if="showContainerName">
|
||||
<ContainerName :id="logEntry.containerID" />
|
||||
</div>
|
||||
<div v-if="showStd">
|
||||
<log-std :std="logEntry.std"></log-std>
|
||||
<LogStd :std="logEntry.std" />
|
||||
</div>
|
||||
<div v-if="showTimestamp">
|
||||
<log-date :date="logEntry.date"></log-date>
|
||||
<LogDate :date="logEntry.date" class="select-none" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<log-level :level="logEntry.level"></log-level>
|
||||
<LogLevel :level="logEntry.level" />
|
||||
</div>
|
||||
<div>
|
||||
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }" @click="expandToggle()">
|
||||
<li v-for="(value, name) in validValues" class="inline-block">
|
||||
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }">
|
||||
<li v-for="(value, name) in validValues" :key="name">
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null"><null></span>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<span class="font-bold" v-html="markSearch(JSON.stringify(value))"> </span>
|
||||
@@ -20,11 +28,11 @@
|
||||
</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
<field-list :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
|
||||
<FieldList :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys" />
|
||||
</div>
|
||||
<log-message-actions
|
||||
<LogMessageActions
|
||||
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
|
||||
:message="JSON.stringify(logEntry.message)"
|
||||
:message="() => JSON.stringify(logEntry.message)"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</div>
|
||||
@@ -34,9 +42,10 @@ import { type ComplexLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
const { logEntry, showContainerName = false } = defineProps<{
|
||||
logEntry: ComplexLogEntry;
|
||||
visibleKeys: string[][];
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const [expanded, expandToggle] = useToggle();
|
||||
@@ -52,18 +61,8 @@ const validValues = computed(() => {
|
||||
}
|
||||
|
||||
.fields {
|
||||
&:hover {
|
||||
&::after {
|
||||
content: "expand json";
|
||||
@apply ml-2 inline-block text-secondary;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded:hover {
|
||||
&::after {
|
||||
content: "collapse json";
|
||||
}
|
||||
li {
|
||||
@apply inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col">
|
||||
<div class="relative flex items-start gap-x-2">
|
||||
<ContainerName class="flex-none" :id="logEntry.containerID" v-if="showContainerName" />
|
||||
<LogDate :date="logEntry.date" v-if="showTimestamp" />
|
||||
<LogLevel class="flex" />
|
||||
<div class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-8 w-auto flex-none font-sans text-[1rem] md:mx-auto md:w-1/2" v-if="followEligible">
|
||||
<carbon:information class="size-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">{{ $t("alert.similar-container-found.title") }}</h3>
|
||||
{{ $t("alert.similar-container-found.message", { containerId: nextContainer.id }) }}
|
||||
</div>
|
||||
<div>
|
||||
<TimedButton v-if="automaticRedirect" class="btn-primary btn-sm" @finished="redirectNow()">{{
|
||||
$t("button.cancel")
|
||||
}}</TimedButton>
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: nextContainer.id } }"
|
||||
class="btn btn-primary btn-sm"
|
||||
v-else
|
||||
>
|
||||
{{ $t("button.redirect") }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ContainerEventLogEntry } from "@/models/LogEntry";
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: ContainerEventLogEntry;
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const store = useContainerStore();
|
||||
|
||||
const { containers: allContainers } = storeToRefs(store);
|
||||
|
||||
const nextContainer = computed(
|
||||
() =>
|
||||
[
|
||||
...allContainers.value.filter(
|
||||
(c) =>
|
||||
c.host === containers.value[0].host &&
|
||||
c.created > logEntry.date &&
|
||||
c.name === containers.value[0].name &&
|
||||
c.state === "running",
|
||||
),
|
||||
].sort((a, b) => +a.created - +b.created)[0],
|
||||
);
|
||||
|
||||
const followEligible = computed(
|
||||
() =>
|
||||
router.currentRoute.value.name === "/container/[id]" &&
|
||||
logEntry.event === "container-stopped" &&
|
||||
containers.value.length === 1 &&
|
||||
nextContainer.value !== undefined,
|
||||
);
|
||||
|
||||
function redirectNow() {
|
||||
showToast(
|
||||
{
|
||||
title: t("alert.redirected.title"),
|
||||
message: t("alert.redirected.message", { containerId: nextContainer.value?.id }),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 5000 },
|
||||
);
|
||||
router.push({ name: "/container/[id]", params: { id: nextContainer.value?.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[data-event="container-stopped"] {
|
||||
@apply text-red;
|
||||
}
|
||||
[data-event="container-started"] {
|
||||
@apply text-green;
|
||||
}
|
||||
</style>
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<log-viewer :messages="filtered" :last-selected-item="lastSelectedItem" :visible-keys="visibleKeys" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
}>();
|
||||
|
||||
const { container } = useContainerContext();
|
||||
|
||||
const visibleKeys = persistentVisibleKeys(container);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages } = useSearchFilter();
|
||||
|
||||
const { messages } = toRefs(props);
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
const { lastSelectedItem } = useLogSearchContext() as {
|
||||
lastSelectedItem: Ref<LogEntry<string | JSONObject> | undefined>;
|
||||
};
|
||||
const routeHash = useRouteHash();
|
||||
watch(
|
||||
routeHash,
|
||||
(hash) => {
|
||||
if (hash) {
|
||||
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: "post" },
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss"></style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="grid w-40 overflow-hidden rounded text-center text-sm text-white">
|
||||
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
|
||||
<div class="col-start-1 row-start-1 truncate px-2 brightness-100 [direction:rtl]">{{ containerNames[id] }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
const colors = [
|
||||
"#4B0082",
|
||||
"#FF00FF",
|
||||
"#FF7F00",
|
||||
"#FFFF00",
|
||||
"#00FF00",
|
||||
"#00FFFF",
|
||||
"#FF0000",
|
||||
"#0000FF",
|
||||
"#FF007F",
|
||||
"#32CD32",
|
||||
"#40E0D0",
|
||||
"#E6E6FA",
|
||||
"#800080",
|
||||
"#FFD700",
|
||||
"#FF4040",
|
||||
] as const;
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
const containerStore = useContainerStore();
|
||||
const { containerNames } = storeToRefs(containerStore);
|
||||
|
||||
const { id } = defineProps<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const color = computed(() => colors[Math.abs(hashCode(id)) % colors.length]);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.random-color {
|
||||
background-color: v-bind(color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-4" v-if="container.stat">
|
||||
<stat-monitor :data="memoryData" label="mem" :stat-value="formatBytes(unref(container.stat).memoryUsage)" />
|
||||
<stat-monitor :data="cpuData" label="load" :stat-value="Math.max(0, unref(container.stat).cpu).toFixed(2) + '%'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { container } = useContainerContext();
|
||||
|
||||
const cpuData = computedWithControl(
|
||||
() => container.value.stat,
|
||||
() => {
|
||||
const history = container.value.statsHistory;
|
||||
const points: Point<unknown>[] = history.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu).toFixed(2) + "%",
|
||||
}));
|
||||
return points;
|
||||
},
|
||||
);
|
||||
|
||||
const memoryData = computedWithControl(
|
||||
() => container.value.stat,
|
||||
() => {
|
||||
const history = container.value.statsHistory;
|
||||
const points: Point<string>[] = history.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.memory,
|
||||
value: formatBytes(stat.memoryUsage),
|
||||
}));
|
||||
return points;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-1 font-sans text-[0.9rem]">
|
||||
<span class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></span>
|
||||
<div
|
||||
class="alert alert-info mt-8 w-auto text-[1rem] md:mx-auto md:w-1/2"
|
||||
v-if="nextContainer && logEntry.event === 'container-stopped'"
|
||||
>
|
||||
<carbon:information class="size-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">{{ $t("alert.similar-container-found.title") }}</h3>
|
||||
{{ $t("alert.similar-container-found.message", { containerId: nextContainer.id }) }}
|
||||
</div>
|
||||
<div>
|
||||
<TimedButton v-if="automaticRedirect" class="btn-primary btn-sm" @finished="redirectNow()">Cancel</TimedButton>
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: nextContainer.id } }"
|
||||
class="btn btn-primary btn-sm"
|
||||
v-else
|
||||
>
|
||||
Redirect
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { DockerEventLogEntry } from "@/models/LogEntry";
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: DockerEventLogEntry;
|
||||
}>();
|
||||
|
||||
const store = useContainerStore();
|
||||
const { containers } = storeToRefs(store);
|
||||
const { container } = useContainerContext();
|
||||
|
||||
const nextContainer = computed(
|
||||
() =>
|
||||
containers.value
|
||||
.filter(
|
||||
(c) =>
|
||||
c.host === container.value.host &&
|
||||
c.created > logEntry.date &&
|
||||
c.name === container.value.name &&
|
||||
c.state === "running",
|
||||
)
|
||||
.toSorted((a, b) => +a.created - +b.created)[0],
|
||||
);
|
||||
|
||||
function redirectNow() {
|
||||
showToast(
|
||||
{
|
||||
title: t("alert.redirected.title"),
|
||||
message: t("alert.redirected.message", { containerId: nextContainer.value.id }),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 5000 },
|
||||
);
|
||||
router.push({ name: "container-id", params: { id: nextContainer.value.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[data-event="container-stopped"] {
|
||||
@apply text-red;
|
||||
}
|
||||
[data-event="container-started"] {
|
||||
@apply text-green;
|
||||
}
|
||||
</style>
|
||||
+16
-13
@@ -1,6 +1,5 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { containerContext } from "@/composable/containerContext";
|
||||
import { useSearchFilter } from "@/composable/search";
|
||||
import { settings } from "@/stores/settings";
|
||||
// @ts-ignore
|
||||
@@ -9,8 +8,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { computed, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
import ContainerLogViewer from "./ContainerLogViewer.vue";
|
||||
import { default as Component } from "./EventSource.vue";
|
||||
import LogViewer from "@/components/LogViewer/LogViewer.vue";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
@@ -21,7 +21,7 @@ vi.mock("@/stores/config", () => ({
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
describe("<LogEventSource />", () => {
|
||||
describe("<ContainerEventSource />", () => {
|
||||
const search = useSearchFilter();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -67,32 +67,35 @@ describe("<LogEventSource />", () => {
|
||||
],
|
||||
});
|
||||
|
||||
return mount(LogEventSource, {
|
||||
return mount(Component<Container>, {
|
||||
global: {
|
||||
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
|
||||
components: {
|
||||
ContainerLogViewer,
|
||||
LogViewer,
|
||||
},
|
||||
provide: {
|
||||
[containerContext as symbol]: {
|
||||
container: computed(() => ({ id: "abc", image: "test:v123", host: "localhost" })),
|
||||
scrollingPaused: computed(() => false),
|
||||
[loggingContextKey as symbol]: {
|
||||
containers: computed(() => [{ id: "abc", image: "test:v123", host: "localhost" }]),
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
},
|
||||
scrollingPaused: computed(() => false),
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: `
|
||||
<template #scoped="params"><container-log-viewer :messages="params.messages" /></template>
|
||||
<template #scoped="params"><LogViewer :messages="params.messages" :show-container-name="false" :visible-keys="[]" /></template>
|
||||
`,
|
||||
},
|
||||
props: {},
|
||||
props: {
|
||||
streamSource: useContainerStream,
|
||||
entity: new Container("abc", new Date(), "image", "name", "command", "localhost", {}, "status", "created", []),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sourceUrl = "/api/logs/stream/localhost/abc?stdout=1&stderr=1";
|
||||
const sourceUrl = "/api/hosts/localhost/containers/abc/logs/stream?stdout=1&stderr=1";
|
||||
|
||||
test("renders correctly", async () => {
|
||||
test("renders loading correctly", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="messages.length > 50"></InfiniteLoader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="T">
|
||||
import { LogStreamSource } from "@/composable/eventStreams";
|
||||
|
||||
const loadingMore = defineEmit<[value: boolean]>();
|
||||
|
||||
const { entity, streamSource } = $defineProps<{
|
||||
streamSource: (t: Ref<T>) => LogStreamSource;
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
const { messages, loadOlderLogs } = streamSource($$(entity));
|
||||
|
||||
const beforeLoading = () => loadingMore(true);
|
||||
const afterLoading = () => loadingMore(false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = async () => {
|
||||
beforeLoading();
|
||||
await loadOlderLogs();
|
||||
afterLoading();
|
||||
};
|
||||
</script>
|
||||
@@ -1,19 +1,14 @@
|
||||
<template>
|
||||
<ul v-if="expanded" ref="root" class="ml-8">
|
||||
<li v-for="(value, name) in fields">
|
||||
<li v-for="(value, name) in fields" :key="name">
|
||||
<template v-if="isObject(value)">
|
||||
<span class="text-light">{{ name }}=</span>
|
||||
<field-list
|
||||
:fields="value"
|
||||
:parent-key="parentKey.concat(name)"
|
||||
:visible-keys="visibleKeys"
|
||||
expanded
|
||||
></field-list>
|
||||
<FieldList :fields="value" :parent-key="parentKey.concat(name)" :visible-keys="visibleKeys" expanded />
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<a @click="toggleField(name)" class="link-primary cursor-pointer">
|
||||
{{ hasField(name) ? "remove" : "add" }} </a
|
||||
>
|
||||
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">
|
||||
{{ hasField(name) ? "remove" : "add" }}
|
||||
</a>
|
||||
<span class="text-light">{{ name }}=</span>[
|
||||
<span class="font-bold" v-for="(item, index) in value">
|
||||
<span v-html="JSON.stringify(item)"></span><span v-if="index !== value.length - 1">,</span>
|
||||
@@ -21,9 +16,9 @@
|
||||
]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a @click="toggleField(name)" class="link-primary cursor-pointer">
|
||||
{{ hasField(name) ? "remove" : "add" }} </a
|
||||
>
|
||||
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">
|
||||
{{ hasField(name) ? "remove" : "add" }}
|
||||
</a>
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-html="value"></span>
|
||||
</template>
|
||||
</li>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<scrollable-view :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<container-title @close="$emit('close')" />
|
||||
<container-stat class="ml-auto" />
|
||||
|
||||
<log-actions-toolbar @clear="onClearClicked()" class="mobile-hidden" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
|
||||
|
||||
const {
|
||||
id,
|
||||
showTitle = false,
|
||||
scrollable = false,
|
||||
closable = false,
|
||||
} = defineProps<{
|
||||
id: string;
|
||||
showTitle?: boolean;
|
||||
scrollable?: boolean;
|
||||
closable?: boolean;
|
||||
}>();
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer($$(id));
|
||||
provideContainerContext(container);
|
||||
|
||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
|
||||
|
||||
function onClearClicked() {
|
||||
viewer.value?.clear();
|
||||
}
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
onClearClicked();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<tag size="small">
|
||||
<date-time :date="date" class="whitespace-nowrap text-blue"></date-time>
|
||||
</tag>
|
||||
<Tag size="small">
|
||||
<DateTime :date="date" class="whitespace-nowrap text-blue" />
|
||||
</Tag>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const loadingMore = defineEmit<[value: boolean]>();
|
||||
|
||||
const { messages, loadOlderLogs } = useLogStream();
|
||||
|
||||
const beforeLoading = () => loadingMore(true);
|
||||
const afterLoading = () => loadingMore(false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
|
||||
</script>
|
||||
@@ -43,6 +43,8 @@ defineProps<{
|
||||
}
|
||||
|
||||
[data-level="error"],
|
||||
[data-level="severe"],
|
||||
[data-level="critical"],
|
||||
[data-level="fatal"] {
|
||||
@apply bg-red;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<ul
|
||||
class="events group py-4"
|
||||
:class="{ 'disable-wrap': !softWrap, [size]: true, compact }"
|
||||
v-if="messages.length > 0"
|
||||
>
|
||||
<li
|
||||
v-for="item in messages"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
class="group/entry"
|
||||
>
|
||||
<component
|
||||
:is="item.getComponent()"
|
||||
:log-entry="item"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="showContainerName"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="m-4 text-center">
|
||||
<span class="loading loading-ring loading-md text-primary"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRaw } from "vue";
|
||||
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
visibleKeys: string[][];
|
||||
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
|
||||
showContainerName: boolean;
|
||||
}>();
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.events {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monaco,
|
||||
Menlo,
|
||||
monospace;
|
||||
|
||||
> li {
|
||||
@apply flex break-words px-2 py-1 last:snap-end odd:bg-gray-400/[0.07] md:px-4;
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
|
||||
.jump-context {
|
||||
@apply mr-2 flex items-center font-sans text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply text-[0.7em];
|
||||
}
|
||||
|
||||
&.medium {
|
||||
@apply text-[0.8em];
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
> li {
|
||||
@apply py-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
|
||||
v-if="isSupported && message.trim() != ''"
|
||||
v-if="isSupported"
|
||||
:title="t('log_actions.copy_log')"
|
||||
>
|
||||
<span
|
||||
@@ -32,7 +32,7 @@
|
||||
import { LogEntry, JSONObject } from "@/models/LogEntry";
|
||||
|
||||
const { message, logEntry } = defineProps<{
|
||||
message: string;
|
||||
message: () => string;
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
}>();
|
||||
|
||||
@@ -44,7 +44,7 @@ const { isSearching } = useSearchFilter();
|
||||
const { handleJumpLineSelected } = useLogSearchContext();
|
||||
|
||||
async function copyLogMessageToClipBoard() {
|
||||
await copy(message);
|
||||
await copy(message());
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<tag size="small" :std="std">
|
||||
<Tag size="small" :std="std">
|
||||
{{ std }}
|
||||
</tag>
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,67 +1,43 @@
|
||||
<template>
|
||||
<ul class="events group py-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
|
||||
<li
|
||||
v-for="item in messages"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
class="group/entry"
|
||||
>
|
||||
<component :is="item.getComponent()" :log-entry="item" :visible-keys="visibleKeys" />
|
||||
</li>
|
||||
</ul>
|
||||
<LogList
|
||||
:messages="filtered"
|
||||
:last-selected-item="lastSelectedItem"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="showContainerName"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRaw } from "vue";
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
visibleKeys: string[][];
|
||||
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
|
||||
showContainerName: boolean;
|
||||
}>();
|
||||
|
||||
const { messages, visibleKeys } = toRefs(props);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages } = useSearchFilter();
|
||||
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
const { lastSelectedItem } = useLogSearchContext() as {
|
||||
lastSelectedItem: Ref<LogEntry<string | JSONObject> | undefined>;
|
||||
};
|
||||
const routeHash = useRouteHash();
|
||||
watch(
|
||||
routeHash,
|
||||
(hash) => {
|
||||
if (hash) {
|
||||
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: "post" },
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.events {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monaco,
|
||||
Menlo,
|
||||
monospace;
|
||||
|
||||
> li {
|
||||
@apply flex break-words px-2 py-1 last:snap-end odd:bg-gray-400/[0.07] md:px-4;
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
|
||||
.jump-context {
|
||||
@apply mr-2 flex items-center font-sans text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply text-[0.7em];
|
||||
}
|
||||
|
||||
&.medium {
|
||||
@apply text-[0.8em];
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
> li {
|
||||
@apply py-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="postcss"></style>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<log-event-source ref="source" #default="{ messages }" @loading-more="loadingMore($event)">
|
||||
<container-log-viewer :messages="messages" />
|
||||
</log-event-source>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
|
||||
const loadingMore = defineEmit<[value: boolean]>();
|
||||
const source = $ref<InstanceType<typeof LogEventSource>>();
|
||||
function clear() {
|
||||
source?.clear();
|
||||
}
|
||||
defineExpose({
|
||||
clear,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="flex gap-4">
|
||||
<StatMonitor :data="memoryData" label="mem" :stat-value="formatBytes(totalStat.memoryUsage)" />
|
||||
<StatMonitor :data="cpuData" label="load" :stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Stat } from "@/models/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
|
||||
watch(
|
||||
() => containers,
|
||||
() => {
|
||||
const initial: Stat[] = [];
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = containers.reduce(
|
||||
(acc, { statsHistory }) => {
|
||||
const item = statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu,
|
||||
memory: acc.memory + item.memory,
|
||||
memoryUsage: acc.memoryUsage + item.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
);
|
||||
initial.push(stat);
|
||||
}
|
||||
reset({ initial: initial.reverse() });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
useIntervalFn(() => {
|
||||
totalStat.value = containers.reduce(
|
||||
(acc, { stat }) => {
|
||||
return {
|
||||
cpu: acc.cpu + stat.cpu,
|
||||
memory: acc.memory + stat.memory,
|
||||
memoryUsage: acc.memoryUsage + stat.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
const cpuData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu).toFixed(2) + "%",
|
||||
})),
|
||||
);
|
||||
|
||||
const memoryData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.memoryUsage,
|
||||
value: formatBytes(stat.memoryUsage),
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="relative flex w-full items-start gap-x-2">
|
||||
<log-std :std="logEntry.std" v-if="showStd" />
|
||||
<log-date :date="logEntry.date" v-if="showTimestamp" />
|
||||
<log-level class="flex" :level="logEntry.level" :position="logEntry.position" />
|
||||
<LogStd :std="logEntry.std" v-if="showStd" />
|
||||
<ContainerName class="flex-none" :id="logEntry.containerID" v-if="showContainerName" />
|
||||
<LogDate :date="logEntry.date" v-if="showTimestamp" class="select-none" />
|
||||
<LogLevel class="flex" :level="logEntry.level" :position="logEntry.position" />
|
||||
<div
|
||||
class="whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap"
|
||||
v-html="markSearch(logEntry.message)"
|
||||
v-html="colorize(logEntry.message)"
|
||||
></div>
|
||||
<log-message-actions
|
||||
<LogMessageActions
|
||||
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
|
||||
:message="decodeXML(logEntry.message)"
|
||||
:message="() => decodeXML(stripAnsi(logEntry.message))"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</div>
|
||||
@@ -17,10 +18,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import { decodeXML } from "entities";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
defineProps<{
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--base-content-color)",
|
||||
bg: "var(--base-color)",
|
||||
});
|
||||
|
||||
const { showContainerName = false } = defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="my-4 flex-1 text-center">
|
||||
<div class="relative">
|
||||
<zig-zag class="absolute inset-0 mt-2"></zig-zag>
|
||||
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">{{
|
||||
$t("error.logs-skipped", { total: logEntry.totalSkipped })
|
||||
}}</span>
|
||||
<ZigZag class="absolute inset-0 mt-2" />
|
||||
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">
|
||||
{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative hover:text-secondary" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
|
||||
<div class="hidden overflow-hidden rounded-sm border border-primary px-px pb-px pt-1 md:flex">
|
||||
<stat-sparkline :data="data" @selected-point="onSelectedPoint"></stat-sparkline>
|
||||
<StatSparkline :data="data" @selected-point="onSelectedPoint" />
|
||||
</div>
|
||||
<div class="inline-flex gap-1 rounded bg-base p-px text-xs md:absolute md:-left-0.5 md:-top-2">
|
||||
<div class="font-light uppercase">{{ label }}</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<EventSource
|
||||
ref="source"
|
||||
#default="{ messages }"
|
||||
@loading-more="loadingMore($event)"
|
||||
:stream-source="streamSource"
|
||||
:entity="entity"
|
||||
>
|
||||
<LogViewer :messages="messages" :visible-keys="visibleKeys" :show-container-name="showContainerName" />
|
||||
</EventSource>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="T">
|
||||
import LogEventSource from "@/components/ContainerViewer/LogEventSource.vue";
|
||||
import { LogStreamSource } from "@/composable/eventStreams";
|
||||
|
||||
const { streamSource, visibleKeys, showContainerName, entity } = defineProps<{
|
||||
streamSource: (t: Ref<T>) => LogStreamSource;
|
||||
visibleKeys: string[][];
|
||||
showContainerName: boolean;
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
const loadingMore = defineEmit<[value: boolean]>();
|
||||
|
||||
const source = $ref<InstanceType<typeof LogEventSource>>();
|
||||
|
||||
defineExpose({
|
||||
clear: () => source?.clear(),
|
||||
});
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
source?.clear();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
+34
-28
@@ -1,11 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul data-v-2e92daca="" class="events group py-4 medium">
|
||||
<li data-v-2e92daca="" data-key="1" class="group/entry">
|
||||
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group py-4 medium">
|
||||
<li data-v-cf9ff940="" data-key="1" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
@@ -19,12 +20,13 @@ exports[`<LogEventSource /> > render html correctly > should render dates with 1
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul data-v-2e92daca="" class="events group py-4 medium">
|
||||
<li data-v-2e92daca="" data-key="1" class="group/entry">
|
||||
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group py-4 medium">
|
||||
<li data-v-cf9ff940="" data-key="1" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
@@ -38,12 +40,13 @@ exports[`<LogEventSource /> > render html correctly > should render dates with 2
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul data-v-2e92daca="" class="events group py-4 medium">
|
||||
<li data-v-2e92daca="" data-key="1" class="group/entry">
|
||||
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group py-4 medium">
|
||||
<li data-v-cf9ff940="" data-key="1" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
@@ -57,12 +60,13 @@ exports[`<LogEventSource /> > render html correctly > should render messages 1`]
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul data-v-2e92daca="" class="events group py-4 medium">
|
||||
<li data-v-2e92daca="" data-key="2" class="group/entry">
|
||||
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group py-4 medium">
|
||||
<li data-v-cf9ff940="" data-key="2" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
@@ -78,12 +82,13 @@ exports[`<LogEventSource /> > render html correctly > should render messages wit
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul data-v-2e92daca="" class="events group py-4 medium">
|
||||
<li data-v-2e92daca="" data-key="1" class="group/entry">
|
||||
<div data-v-2e92daca="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group py-4 medium">
|
||||
<li data-v-cf9ff940="" data-key="1" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" size="small">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
@@ -97,14 +102,15 @@ exports[`<LogEventSource /> > render html correctly > should render messages wit
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > renders correctly 1`] = `
|
||||
exports[`<ContainerEventSource /> > renders loading correctly 1`] = `
|
||||
"<div class="flex min-h-[1px] justify-center"><span class="loading loading-bars loading-md mt-4 text-primary" style="display: none;"></span></div>
|
||||
<ul data-v-2e92daca="" class="events group py-4 medium"></ul>"
|
||||
<div data-v-cf9ff940="" class="m-4 text-center"><span data-v-cf9ff940="" class="loading loading-ring loading-md text-primary"></span></div>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages 1`] = `
|
||||
exports[`<ContainerEventSource /> > should parse messages 1`] = `
|
||||
SimpleLogEntry {
|
||||
"_message": "This is a message.",
|
||||
"containerID": undefined,
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"id": 1,
|
||||
"level": undefined,
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="containers.length && ready">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ containers.length }} containers</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
@loading-more="setLoading($event)"
|
||||
:stream-source="useMergedStream"
|
||||
:entity="containers"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
|
||||
const { ids = [], scrollable = false } = defineProps<{
|
||||
ids?: string[];
|
||||
scrollable?: boolean;
|
||||
}>();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
const { allContainersById, ready } = storeToRefs(containerStore);
|
||||
|
||||
const containers = computed(() => ids.map((id) => allContainersById.value[id]));
|
||||
|
||||
const visibleKeys = ref<string[][]>([]);
|
||||
|
||||
provideLoggingContext(containers);
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 px-4 py-4 md:px-8">
|
||||
<section>
|
||||
<links />
|
||||
<Links />
|
||||
</section>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
@@ -16,22 +16,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { globalShowPopup } from "@/composable/popup";
|
||||
|
||||
let glopbalShow = globalShowPopup();
|
||||
let show = ref(glopbalShow.value);
|
||||
let delayedShow = refDebounced(show, 1000);
|
||||
|
||||
let content: HTMLElement | null = $ref(null);
|
||||
const glopbalShow = globalShowPopup();
|
||||
const show = ref(glopbalShow.value);
|
||||
const delayedShow = refDebounced(show, 1000);
|
||||
const content = ref<HTMLElement>();
|
||||
|
||||
const onMouseEnter = (e: Event) => {
|
||||
show.value = true;
|
||||
glopbalShow.value = true;
|
||||
if (e.target && content && e.target instanceof Element) {
|
||||
|
||||
if (content.value && e.target instanceof HTMLElement) {
|
||||
const { left, top, width } = e.target.getBoundingClientRect();
|
||||
const x = left + width + 10;
|
||||
const y = top;
|
||||
|
||||
content.style.left = `${x}px`;
|
||||
content.style.top = `${y}px`;
|
||||
content.value.style.left = `${x}px`;
|
||||
content.value.style.top = `${y}px`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><distance-time :date="new Date(release.createdAt)" /></span>
|
||||
<tag class="ml-auto bg-red px-1 py-1 text-xs" v-if="release.tag === latest?.tag">
|
||||
<Tag class="ml-auto bg-red px-1 py-1 text-xs" v-if="release.tag === latest?.tag">
|
||||
{{ $t("releases.latest") }}
|
||||
</tag>
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/80">
|
||||
{{ summary(release) }}
|
||||
|
||||
@@ -27,15 +27,17 @@ const { indeterminate = false, autoHide = false } = defineProps<{
|
||||
|
||||
const scrollProgress = ref(0);
|
||||
const root = ref<HTMLElement>();
|
||||
const store = useContainerStore();
|
||||
const { activeContainers } = storeToRefs(store);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const scrollElement = ref<HTMLElement | Document>((root.value?.closest("[data-scrolling]") as HTMLElement) ?? document);
|
||||
const { y: scrollY } = useScroll(scrollElement as Ref<HTMLElement | Document>, { throttle: 100 });
|
||||
const show = autoResetRef(false, 2000);
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
activeContainers,
|
||||
pinnedLogs,
|
||||
() => {
|
||||
scrollElement.value = (root.value?.closest("[data-scrolling]") as HTMLElement) ?? document;
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</header>
|
||||
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
|
||||
<div class="invisible mr-28 text-right md:visible" v-show="paused">
|
||||
<scroll-progress :indeterminate="loading" :auto-hide="!loading" class="!fixed top-16 z-10" />
|
||||
<ScrollProgress :indeterminate="loading" :auto-hide="!loading" class="!fixed top-16 z-10" />
|
||||
</div>
|
||||
<div ref="scrollableContent">
|
||||
<slot :setLoading="setLoading"></slot>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="service.name">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ service.name }}</div>
|
||||
</div>
|
||||
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
|
||||
{{ $t("label.container", service.containers.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="service.containers" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
@loading-more="setLoading($event)"
|
||||
:stream-source="useServiceStream"
|
||||
:entity="service"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Service } from "@/models/Stack";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
|
||||
const { name, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
name: string;
|
||||
}>();
|
||||
|
||||
const visibleKeys = ref<string[][]>([]);
|
||||
|
||||
const store = useSwarmStore();
|
||||
const { services } = storeToRefs(store) as unknown as { services: Ref<Service[]> };
|
||||
const service = computed(() => services.value.find((s) => s.name === name) ?? new Service("", []));
|
||||
|
||||
provideLoggingContext(toRef(() => service.value.containers));
|
||||
</script>
|
||||
+24
-189
@@ -1,59 +1,17 @@
|
||||
<template>
|
||||
<div v-if="ready" data-testid="side-menu">
|
||||
<div class="breadcrumbs">
|
||||
<ul>
|
||||
<li><a @click.prevent="setHost(null)" class="link-primary">Hosts</a></li>
|
||||
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
|
||||
{{ hosts[sessionHost].name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<transition :name="sessionHost ? 'slide-left' : 'slide-right'" mode="out-in">
|
||||
<ul class="menu p-0" v-if="!sessionHost">
|
||||
<li v-for="host in config.hosts">
|
||||
<a @click.prevent="setHost(host.id)">
|
||||
<ph:computer-tower />
|
||||
{{ host.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<transition-group tag="ul" name="list" class="containers menu p-0 [&_li.menu-title]:px-0" v-else>
|
||||
<li
|
||||
v-for="item in menuItems"
|
||||
:key="isContainer(item) ? item.id : item.keyLabel"
|
||||
:class="isContainer(item) ? item.state : 'menu-title'"
|
||||
:data-testid="isContainer(item) ? null : item.keyLabel"
|
||||
>
|
||||
<popup v-if="isContainer(item)">
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
active-class="active-primary"
|
||||
@click.alt.stop.prevent="store.appendActiveContainer(item)"
|
||||
:title="item.name"
|
||||
>
|
||||
<div class="truncate">
|
||||
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">{{ item.swarmId }}</span>
|
||||
</div>
|
||||
<container-health :health="item.health"></container-health>
|
||||
<span
|
||||
class="pin"
|
||||
@click.stop.prevent="store.appendActiveContainer(item)"
|
||||
v-show="!activeContainersById[item.id]"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
>
|
||||
<cil:columns />
|
||||
</span>
|
||||
</router-link>
|
||||
<template #content>
|
||||
<container-popup :container="item"></container-popup>
|
||||
</template>
|
||||
</popup>
|
||||
<template v-else>
|
||||
{{ $t(item.keyLabel) }}
|
||||
</template>
|
||||
</li>
|
||||
</transition-group>
|
||||
</transition>
|
||||
<Toggle v-model="showSwarm" v-if="services.length > 0 || customGroups.length > 0">
|
||||
<div class="text-lg font-light">{{ $t("label.swarm-mode") }}</div>
|
||||
</Toggle>
|
||||
|
||||
<SlideTransition :slide-right="showSwarm">
|
||||
<template #left>
|
||||
<HostMenu />
|
||||
</template>
|
||||
<template #right>
|
||||
<SwarmMenu />
|
||||
</template>
|
||||
</SlideTransition>
|
||||
</div>
|
||||
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
|
||||
<div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
|
||||
@@ -62,144 +20,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const store = useContainerStore();
|
||||
const swarmStore = useSwarmStore();
|
||||
const { services, customGroups } = storeToRefs(swarmStore);
|
||||
|
||||
const { activeContainers, visibleContainers, ready } = storeToRefs(store);
|
||||
const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
|
||||
|
||||
function setHost(host: string | null) {
|
||||
sessionHost.value = host;
|
||||
}
|
||||
|
||||
const debouncedIds = debouncedRef(pinnedContainers, 200);
|
||||
const sortedContainers = computed(() =>
|
||||
visibleContainers.value
|
||||
.filter((c) => c.host === sessionHost.value)
|
||||
.sort((a, b) => {
|
||||
if (a.state === "running" && b.state !== "running") {
|
||||
return -1;
|
||||
} else if (a.state !== "running" && b.state === "running") {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const groupedContainers = computed(() =>
|
||||
sortedContainers.value.reduce(
|
||||
(acc, item) => {
|
||||
if (debouncedIds.value.has(item.name)) {
|
||||
acc.pinned.push(item);
|
||||
} else {
|
||||
acc.unpinned.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ pinned: [] as Container[], unpinned: [] as Container[] },
|
||||
),
|
||||
);
|
||||
|
||||
function isContainer(item: any): item is Container {
|
||||
return item.hasOwnProperty("image");
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const pinnedLabel = { keyLabel: "label.pinned" };
|
||||
const allLabel = { keyLabel: showAllContainers.value ? "label.all-containers" : "label.running-containers" };
|
||||
if (groupedContainers.value.pinned.length > 0) {
|
||||
return [pinnedLabel, ...groupedContainers.value.pinned, allLabel, ...groupedContainers.value.unpinned];
|
||||
} else {
|
||||
return [allLabel, ...groupedContainers.value.unpinned];
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (to.meta.swarmMode) {
|
||||
showSwarm.value = true;
|
||||
} else if (to.meta.containerMode) {
|
||||
showSwarm.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const hosts = computed(() =>
|
||||
config.hosts.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { name: string; id: string }>,
|
||||
),
|
||||
);
|
||||
|
||||
const activeContainersById = computed(() =>
|
||||
activeContainers.value.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Container>,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.menu {
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
.containers a {
|
||||
@apply auto-cols-[auto_max-content_max-content];
|
||||
.pin {
|
||||
display: none;
|
||||
|
||||
&:hover {
|
||||
@apply text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
li.exited {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.19s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="postcss"></style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<aside class="fixed h-screen w-[inherit] overflow-auto p-3" data-testid="navigation">
|
||||
<h1>
|
||||
<router-link :to="{ name: 'index' }">
|
||||
<router-link :to="{ name: '/' }">
|
||||
<svg class="h-14 w-28 fill-secondary">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
@@ -12,15 +12,16 @@
|
||||
</small>
|
||||
</h1>
|
||||
|
||||
<a
|
||||
<button
|
||||
class="input input-sm mt-4 inline-flex cursor-pointer items-center gap-2 font-light hover:border-primary"
|
||||
@click="$emit('search')"
|
||||
:title="$t('tooltip.search')"
|
||||
data-testid="search"
|
||||
>
|
||||
<mdi:magnify />
|
||||
Search
|
||||
<key-shortcut char="k"></key-shortcut>
|
||||
</a>
|
||||
{{ $t("placeholder.search") }}
|
||||
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
|
||||
</button>
|
||||
|
||||
<side-menu class="mt-4"></side-menu>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="stack.name">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ stack.name }}</div>
|
||||
</div>
|
||||
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
|
||||
{{ $t("label.container", stack.containers.length) }}
|
||||
</Tag>
|
||||
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
|
||||
{{ $t("label.serivce", stack.services.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
@loading-more="setLoading($event)"
|
||||
:stream-source="useStackStream"
|
||||
:entity="stack"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Stack } from "@/models/Stack";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
|
||||
const { name, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
name: string;
|
||||
}>();
|
||||
|
||||
const visibleKeys = ref<string[][]>([]);
|
||||
|
||||
const store = useSwarmStore();
|
||||
const { stacks } = storeToRefs(store) as unknown as { stacks: Ref<Stack[]> };
|
||||
const stack = computed(() => stacks.value.find((s) => s.name === name) ?? new Stack("", [], []));
|
||||
provideLoggingContext(toRef(() => stack.value.containers));
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<ul class="menu p-0 text-[0.95rem]">
|
||||
<li v-for="{ name, services } in stacks" :key="name">
|
||||
<details open>
|
||||
<summary class="font-light text-base-content/80">
|
||||
<ph:stack />
|
||||
{{ name }}
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/stack/[name]', params: { name } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-services')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="service in services" :key="service.name">
|
||||
<router-link
|
||||
:to="{ name: '/service/[name]', params: { name: service.name } }"
|
||||
active-class="active-primary"
|
||||
>
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">
|
||||
{{ service.name }}
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li v-if="serivcesWithoutStacks.length > 0">
|
||||
<details open>
|
||||
<summary class="font-light text-base-content/80">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.services") }}
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="service in serivcesWithoutStacks" :key="service.name">
|
||||
<router-link
|
||||
:to="{ name: '/service/[name]', params: { name: service.name } }"
|
||||
active-class="active-primary"
|
||||
>
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">
|
||||
{{ service.name }}
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li v-if="customGroups.length > 0">
|
||||
<details open>
|
||||
<summary class="font-light text-base-content/80">
|
||||
<ph:bounding-box-fill />
|
||||
{{ $t("label.custom-groups") }}
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="group in customGroups" :key="group.name">
|
||||
<router-link :to="{ name: '/group/[name]', params: { name: group.name } }" active-class="active-primary">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">
|
||||
{{ group.name }}
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const store = useSwarmStore();
|
||||
|
||||
const { stacks, services, customGroups } = storeToRefs(store);
|
||||
|
||||
const serivcesWithoutStacks = computed(() => services.value.filter((service) => !service.stack));
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.menu {
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<nav class="fixed top-0 z-10 w-full border-b border-base-content/20 bg-base p-2" data-testid="navigation">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: 'index' }">
|
||||
<router-link :to="{ name: '/' }">
|
||||
<svg class="h-14 w-28 fill-secondary">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
@@ -35,7 +35,7 @@
|
||||
<li class="menu-title">{{ $t("label.containers") }}</li>
|
||||
<li v-for="item in sortedContainers" :key="item.id" :class="item.state">
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
:to="{ name: '/container/[id]', params: { id: item.id } }"
|
||||
active-class="active-primary"
|
||||
class="truncate"
|
||||
:title="item.name"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<transition :name="!slideRight ? 'slide-left' : 'slide-right'" mode="out-in">
|
||||
<div v-if="!slideRight">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { slideRight } = defineProps<{ slideRight: boolean }>();
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" :size="size">
|
||||
<div class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" :size="size">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<button class="btn relative overflow-hidden" @click="cancel()">
|
||||
<div class="absolute inset-0 origin-left bg-white/30" ref="progress"></div>
|
||||
<div class="z-10">
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
@@ -13,9 +13,13 @@ const finished = defineEmit();
|
||||
const cancelled = defineEmit();
|
||||
let animation: Animation | undefined;
|
||||
|
||||
const { duration = 4000 } = defineProps<{
|
||||
duration?: number;
|
||||
}>();
|
||||
|
||||
onMounted(async () => {
|
||||
animation = progress.value?.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], {
|
||||
duration: 4000,
|
||||
duration: duration,
|
||||
easing: "linear",
|
||||
fill: "forwards",
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<labeled-input>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
<slot />
|
||||
</template>
|
||||
<template #input>
|
||||
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" />
|
||||
</template>
|
||||
</labeled-input>
|
||||
</LabeledInput>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
type ContainerActions = "start" | "stop" | "restart";
|
||||
export const useContainerActions = () => {
|
||||
const { container } = useContainerContext();
|
||||
export const useContainerActions = (container: Ref<Container>) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
const actionStates = reactive({
|
||||
@@ -10,7 +11,7 @@ export const useContainerActions = () => {
|
||||
});
|
||||
|
||||
async function actionHandler(action: ContainerActions) {
|
||||
const actionUrl = `/api/actions/${action}/${container.value.host}/${container.value.id}`;
|
||||
const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`;
|
||||
|
||||
const errors = {
|
||||
404: "container not found",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
type ContainerContext = {
|
||||
container: Ref<Container>;
|
||||
streamConfig: { stdout: boolean; stderr: boolean };
|
||||
};
|
||||
|
||||
export const containerContext = Symbol("containerContext") as InjectionKey<ContainerContext>;
|
||||
|
||||
export const provideContainerContext = (container: Ref<Container>) => {
|
||||
provide(containerContext, {
|
||||
container,
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useContainerContext = () => {
|
||||
const context = inject(containerContext);
|
||||
if (!context) {
|
||||
throw new Error("No container context provided");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type Ref } from "vue";
|
||||
import { encodeXML } from "entities";
|
||||
import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
type JSONObject,
|
||||
LogEntry,
|
||||
asLogEntry,
|
||||
ContainerEventLogEntry,
|
||||
SkippedLogsEntry,
|
||||
} from "@/models/LogEntry";
|
||||
import { Service, Stack } from "@/models/Stack";
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
const e = JSON.parse(data, (key, value) => {
|
||||
if (typeof value === "string") {
|
||||
return encodeXML(value);
|
||||
}
|
||||
return value;
|
||||
}) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = computed(() => {
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
|
||||
return withBase(
|
||||
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
});
|
||||
|
||||
const loadMoreUrl = computed(() => {
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
|
||||
return withBase(
|
||||
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
});
|
||||
|
||||
return useLogStream(url, loadMoreUrl);
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = computed(() => {
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
|
||||
return withBase(`/api/stacks/${stack.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
|
||||
});
|
||||
|
||||
return useLogStream(url);
|
||||
}
|
||||
|
||||
export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = computed(() => {
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
|
||||
return withBase(`/api/groups/${group.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
|
||||
});
|
||||
|
||||
return useLogStream(url);
|
||||
}
|
||||
|
||||
export function useMergedStream(containers: Ref<Container[]>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = computed(() => {
|
||||
const params = [
|
||||
...Object.entries(streamConfig).map(([key, value]) => [key, value ? "1" : "0"]),
|
||||
...containers.value.map((c) => ["id", c.id]),
|
||||
];
|
||||
|
||||
return withBase(
|
||||
`/api/hosts/${containers.value[0].host}/logs/mergedStream?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
});
|
||||
|
||||
return useLogStream(url);
|
||||
}
|
||||
|
||||
export function useServiceStream(service: Ref<Service>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = computed(() => {
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
|
||||
return withBase(`/api/services/${service.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
|
||||
});
|
||||
|
||||
return useLogStream(url);
|
||||
}
|
||||
|
||||
export type LogStreamSource = ReturnType<typeof useLogStream>;
|
||||
|
||||
function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
let messages: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
let buffer: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>);
|
||||
|
||||
function flushNow() {
|
||||
if (messages.length > config.maxLogs) {
|
||||
if (scrollingPaused) {
|
||||
console.log("Skipping ", buffer.length, " log items");
|
||||
if (messages.at(-1) instanceof SkippedLogsEntry) {
|
||||
const lastEvent = messages.at(-1) as SkippedLogsEntry;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
lastEvent.addSkippedEntries(buffer.length, lastItem);
|
||||
} else {
|
||||
const firstItem = buffer.at(0) as LogEntry<string | JSONObject>;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem));
|
||||
}
|
||||
buffer = [];
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
messages = messages.slice(-config.maxLogs);
|
||||
}
|
||||
} else {
|
||||
if (messages.length == 0) {
|
||||
// sort the buffer the very first time because of multiple logs in parallel
|
||||
buffer.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
}
|
||||
}
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
let es: EventSource | null = null;
|
||||
|
||||
function close() {
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
flushBuffer.cancel();
|
||||
messages = [];
|
||||
buffer = [];
|
||||
}
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
close();
|
||||
|
||||
if (clear) {
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
es = new EventSource(url.value);
|
||||
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string };
|
||||
const containerEvent = new ContainerEventLogEntry(
|
||||
event.name == "container-started" ? "Container started" : "Container stopped",
|
||||
event.actorId,
|
||||
new Date(),
|
||||
event.name as "container-stopped" | "container-started",
|
||||
);
|
||||
buffer.push(containerEvent);
|
||||
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.onmessage = (e) => {
|
||||
if (e.data) {
|
||||
buffer.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
es.onerror = () => clearMessages();
|
||||
}
|
||||
|
||||
watch(url, () => connect(), { immediate: true });
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (!loadMoreUrl) return;
|
||||
|
||||
const to = messages[0].date;
|
||||
const last = messages[Math.min(messages.length - 1, 300)].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
try {
|
||||
const stopWatcher = watchOnce(url, () => abortController.abort("stream changed"));
|
||||
const logs = await (
|
||||
await fetch(
|
||||
`${loadMoreUrl.value}&${new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }).toString()}`,
|
||||
{ signal },
|
||||
)
|
||||
).text();
|
||||
stopWatcher();
|
||||
|
||||
if (logs && signal.aborted === false) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.unshift(...newMessages);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading older logs", e);
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(() => close());
|
||||
|
||||
return { ...$$({ messages }), loadOlderLogs };
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { type Ref } from "vue";
|
||||
import { encodeXML } from "entities";
|
||||
import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
type JSONObject,
|
||||
LogEntry,
|
||||
asLogEntry,
|
||||
DockerEventLogEntry,
|
||||
SkippedLogsEntry,
|
||||
} from "@/models/LogEntry";
|
||||
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
const e = JSON.parse(data, (key, value) => {
|
||||
if (typeof value === "string") {
|
||||
return encodeXML(value);
|
||||
}
|
||||
return value;
|
||||
}) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useLogStream() {
|
||||
const { container, streamConfig } = useContainerContext();
|
||||
let messages: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
let buffer: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>);
|
||||
let containerId = container.value.id;
|
||||
|
||||
function flushNow() {
|
||||
if (messages.length > config.maxLogs) {
|
||||
if (scrollingPaused) {
|
||||
console.log("Skipping ", buffer.length, " log items");
|
||||
if (messages.at(-1) instanceof SkippedLogsEntry) {
|
||||
const lastEvent = messages.at(-1) as SkippedLogsEntry;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
lastEvent.addSkippedEntries(buffer.length, lastItem);
|
||||
} else {
|
||||
const firstItem = buffer.at(0) as LogEntry<string | JSONObject>;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem));
|
||||
}
|
||||
buffer = [];
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
messages = messages.slice(-config.maxLogs);
|
||||
}
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
}
|
||||
}
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
let es: EventSource | null = null;
|
||||
|
||||
function close() {
|
||||
if (es) {
|
||||
es.close();
|
||||
console.debug(`EventSource closed for ${containerId}`);
|
||||
es = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
flushBuffer.cancel();
|
||||
messages = [];
|
||||
buffer = [];
|
||||
console.debug(`Clearing messages for ${containerId}`);
|
||||
}
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
close();
|
||||
|
||||
if (clear) {
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
|
||||
|
||||
containerId = container.value.id;
|
||||
|
||||
console.debug(`Connecting to ${containerId} with params`, params);
|
||||
|
||||
es = new EventSource(
|
||||
withBase(`/api/logs/stream/${container.value.host}/${containerId}?${new URLSearchParams(params).toString()}`),
|
||||
);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
close();
|
||||
buffer.push(new DockerEventLogEntry("Container stopped", new Date(), "container-stopped"));
|
||||
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.onmessage = (e) => {
|
||||
if (e.data) {
|
||||
buffer.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
es.onerror = () => clearMessages();
|
||||
}
|
||||
|
||||
async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
|
||||
if (messages.length < 300) return;
|
||||
|
||||
beforeLoading();
|
||||
const to = messages[0].date;
|
||||
const last = messages[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
|
||||
const params = Object.entries(streamConfig)
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), { from: from.toISOString(), to: to.toISOString() });
|
||||
|
||||
const logs = await (
|
||||
await fetch(
|
||||
withBase(`/api/logs/${container.value.host}/${containerId}?${new URLSearchParams(params).toString()}`),
|
||||
)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.unshift(...newMessages);
|
||||
}
|
||||
afterLoading();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => container.value.state,
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started"));
|
||||
connect({ clear: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => close());
|
||||
|
||||
watch(
|
||||
() => container.value.id,
|
||||
() => connect(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(streamConfig, () => connect());
|
||||
|
||||
return { ...$$({ messages }), loadOlderLogs };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
type LogContext = {
|
||||
streamConfig: { stdout: boolean; stderr: boolean };
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>;
|
||||
|
||||
export const provideLoggingContext = (containers: Ref<Container[]>) => {
|
||||
provide(loggingContextKey, {
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
containers,
|
||||
});
|
||||
};
|
||||
|
||||
export const useLoggingContext = () => {
|
||||
const context = inject(loggingContextKey);
|
||||
if (!context) {
|
||||
throw new Error("No logging context provided");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -25,7 +25,7 @@ function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
export function useSearchFilter() {
|
||||
const regex = $computed(() => {
|
||||
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
|
||||
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "i" : undefined);
|
||||
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "ig" : "g");
|
||||
});
|
||||
|
||||
function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
|
||||
@@ -7,7 +7,7 @@ if (config.hosts.length === 1 && !sessionHost.value) {
|
||||
sessionHost.value = config.hosts[0].id;
|
||||
}
|
||||
|
||||
export function persistentVisibleKeys(container: Ref<Container>): Ref<string[][]> {
|
||||
export function persistentVisibleKeysForContainer(container: Ref<Container>): Ref<string[][]> {
|
||||
const storage = useProfileStorage("visibleKeys", {});
|
||||
return computed(() => {
|
||||
if (!(container.value.storageKey in storage.value)) {
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
<router-view></router-view>
|
||||
</pane>
|
||||
<template v-if="!isMobile">
|
||||
<pane v-for="other in activeContainers" :key="other.id">
|
||||
<log-container
|
||||
<pane v-for="other in pinnedLogs" :key="other.id">
|
||||
<ContainerLog
|
||||
:id="other.id"
|
||||
show-title
|
||||
scrollable
|
||||
closable
|
||||
@close="containerStore.removeActiveContainer(other)"
|
||||
></log-container>
|
||||
@close="pinnedLogsStore.unPinContainer(other)"
|
||||
/>
|
||||
</pane>
|
||||
</template>
|
||||
</splitpanes>
|
||||
@@ -72,8 +72,8 @@
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { collapseNav } from "@/stores/settings";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { activeContainers } = storeToRefs(containerStore);
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const { toasts, removeToast } = useToast();
|
||||
|
||||
|
||||
+14
-30
@@ -1,23 +1,5 @@
|
||||
@import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
@define-mixin light {
|
||||
--base-lighter-color: 100% 0 0;
|
||||
--base-color: 97% 0 0;
|
||||
--base-darker-color: 90% 0 0;
|
||||
--base-content-color: 33% 0 0;
|
||||
--primary-color: 79.96% 0.143 176.65;
|
||||
--secondary-color: 82.67% 0.15380739746702807 77.82;
|
||||
}
|
||||
|
||||
@define-mixin dark {
|
||||
--base-lighter-color: 25% 0 0;
|
||||
--base-color: 18% 0 0;
|
||||
--base-darker-color: 11% 0 0;
|
||||
--base-content-color: 89.23% 0 0;
|
||||
--primary-color: 70.96% 0.143 176.65;
|
||||
--secondary-color: 91.36% 0.11098 90.1243;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -41,21 +23,23 @@
|
||||
--n: var(--base-lighter-color);
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
@mixin dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
@mixin dark;
|
||||
}
|
||||
--base-lighter-color: 25% 0 0;
|
||||
--base-color: 18% 0 0;
|
||||
--base-darker-color: 11% 0 0;
|
||||
--base-content-color: 89.23% 0 0;
|
||||
--primary-color: 70.96% 0.143 176.65;
|
||||
--secondary-color: 91.36% 0.11098 90.1243;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
@mixin light;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html {
|
||||
@mixin light;
|
||||
}
|
||||
--base-lighter-color: 100% 0 0;
|
||||
--base-color: 97% 0 0;
|
||||
--base-darker-color: 90% 0 0;
|
||||
--base-content-color: 33% 0 0;
|
||||
--primary-color: 79.96% 0.143 176.65;
|
||||
--secondary-color: 82.67% 0.15380739746702807 77.82;
|
||||
}
|
||||
|
||||
@media screen and (max-device-width: 480px) {
|
||||
body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { Container } from "./Container";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
}));
|
||||
|
||||
describe("Container", () => {
|
||||
const names = [
|
||||
[
|
||||
"foo.gb1cto7gaq68fp4refnsr5hep.byqr1prci82zyfoos6gx1yhz0",
|
||||
"foo",
|
||||
".gb1cto7gaq68fp4refnsr5hep.byqr1prci82zyfoos6gx1yhz0",
|
||||
],
|
||||
["bar.gb1cto7gaq68fp4refnsr5hep", "bar", ".gb1cto7gaq68fp4refnsr5hep"],
|
||||
["baz", "baz", null],
|
||||
];
|
||||
|
||||
test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => {
|
||||
const c = new Container("id", new Date(), "image", name!, "command", "host", {}, "status", "created", []);
|
||||
expect(c.name).toBe(expectedName);
|
||||
expect(c.swarmId).toBe(expectedSwarmId);
|
||||
});
|
||||
});
|
||||
+39
-14
@@ -2,9 +2,7 @@ import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Con
|
||||
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
|
||||
import { Ref } from "vue";
|
||||
|
||||
type Stat = Omit<ContainerStat, "id">;
|
||||
|
||||
const SWARM_ID_REGEX = /(\.[a-z0-9]{25})+$/i;
|
||||
export type Stat = Omit<ContainerStat, "id">;
|
||||
|
||||
const hosts = computed(() =>
|
||||
config.hosts.reduce(
|
||||
@@ -16,36 +14,39 @@ const hosts = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
export class GroupedContainers {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Container {
|
||||
private _stat: Ref<Stat>;
|
||||
private readonly _statsHistory: Ref<Stat[]>;
|
||||
public readonly swarmId: string | null = null;
|
||||
public readonly isSwarm: boolean = false;
|
||||
private readonly movingAverageStat: Ref<Stat>;
|
||||
private readonly _name: string;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly created: Date,
|
||||
public readonly image: string,
|
||||
public readonly name: string,
|
||||
name: string,
|
||||
public readonly command: string,
|
||||
public readonly host: string,
|
||||
public readonly labels = {} as Record<string, string>,
|
||||
public status: string,
|
||||
public state: ContainerState,
|
||||
stats: Stat[],
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
) {
|
||||
this._stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
this._statsHistory = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
|
||||
this._stat = ref(stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0 } as Stat));
|
||||
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
|
||||
this._statsHistory = history;
|
||||
this.movingAverageStat = useExponentialMovingAverage(this._stat, 0.2);
|
||||
|
||||
const match = name.match(SWARM_ID_REGEX);
|
||||
if (match) {
|
||||
this.swarmId = match[0];
|
||||
this.name = name.replace(`${this.swarmId}`, "");
|
||||
this.isSwarm = true;
|
||||
}
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
get statsHistory() {
|
||||
@@ -68,6 +69,30 @@ export class Container {
|
||||
return `${stripVersion(this.image)}:${this.command}`;
|
||||
}
|
||||
|
||||
get namespace() {
|
||||
return this.labels["com.docker.stack.namespace"] || this.labels["com.docker.compose.project"];
|
||||
}
|
||||
|
||||
get customGroup() {
|
||||
return this.group;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.isSwarm
|
||||
? this.labels["com.docker.swarm.task.name"]
|
||||
.replace(`.${this.labels["com.docker.swarm.task.id"]}`, "")
|
||||
.replace(`.${this.labels["com.docker.swarm.node.id"]}`, "")
|
||||
: this._name;
|
||||
}
|
||||
|
||||
get swarmId() {
|
||||
return this.labels["com.docker.swarm.task.name"].replace(this.name + ".", "");
|
||||
}
|
||||
|
||||
get isSwarm() {
|
||||
return Boolean(this.labels["com.docker.swarm.service.id"]);
|
||||
}
|
||||
|
||||
public updateStat(stat: Stat) {
|
||||
if (isRef(this._stat)) {
|
||||
this._stat.value = stat;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON, getDeep } from "@/utils";
|
||||
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
|
||||
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
|
||||
import DockerEventLogItem from "@/components/LogViewer/DockerEventLogItem.vue";
|
||||
import ContainerEventLogItem from "@/components/LogViewer/ContainerEventLogItem.vue";
|
||||
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
|
||||
|
||||
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
|
||||
@@ -17,12 +17,14 @@ export interface LogEvent {
|
||||
readonly l: Level;
|
||||
readonly p: Position;
|
||||
readonly s: "stdout" | "stderr" | "unknown";
|
||||
readonly c: string;
|
||||
}
|
||||
|
||||
export abstract class LogEntry<T extends string | JSONObject> {
|
||||
protected readonly _message: T;
|
||||
constructor(
|
||||
message: T,
|
||||
public readonly containerID: string,
|
||||
public readonly id: number,
|
||||
public readonly date: Date,
|
||||
public readonly std: Std,
|
||||
@@ -41,13 +43,14 @@ export abstract class LogEntry<T extends string | JSONObject> {
|
||||
export class SimpleLogEntry extends LogEntry<string> {
|
||||
constructor(
|
||||
message: string,
|
||||
containerID: string,
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly position: Position,
|
||||
public readonly std: Std,
|
||||
) {
|
||||
super(message, id, date, std, level);
|
||||
super(message, containerID, id, date, std, level);
|
||||
}
|
||||
getComponent(): Component {
|
||||
return SimpleLogItem;
|
||||
@@ -59,13 +62,14 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
|
||||
constructor(
|
||||
message: JSONObject,
|
||||
containerID: string,
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: Level,
|
||||
public readonly std: Std,
|
||||
visibleKeys?: Ref<string[][]>,
|
||||
) {
|
||||
super(message, id, date, std, level);
|
||||
super(message, containerID, id, date, std, level);
|
||||
if (visibleKeys) {
|
||||
this.filteredMessage = computed(() => {
|
||||
if (!visibleKeys.value.length) {
|
||||
@@ -91,20 +95,29 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
}
|
||||
|
||||
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry {
|
||||
return new ComplexLogEntry(event._message, event.id, event.date, event.level, event.std, visibleKeys);
|
||||
return new ComplexLogEntry(
|
||||
event._message,
|
||||
event.containerID,
|
||||
event.id,
|
||||
event.date,
|
||||
event.level,
|
||||
event.std,
|
||||
visibleKeys,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DockerEventLogEntry extends LogEntry<string> {
|
||||
export class ContainerEventLogEntry extends LogEntry<string> {
|
||||
constructor(
|
||||
message: string,
|
||||
containerID: string,
|
||||
date: Date,
|
||||
public readonly event: "container-stopped" | "container-started",
|
||||
) {
|
||||
super(message, date.getTime(), date, "stderr", "info");
|
||||
super(message, containerID, date.getTime(), date, "stderr", "info");
|
||||
}
|
||||
getComponent(): Component {
|
||||
return DockerEventLogItem;
|
||||
return ContainerEventLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +131,7 @@ export class SkippedLogsEntry extends LogEntry<string> {
|
||||
public readonly firstSkipped: LogEntry<string | JSONObject>,
|
||||
lastSkipped: LogEntry<string | JSONObject>,
|
||||
) {
|
||||
super("", date.getTime(), date, "stderr", "info");
|
||||
super("", "", date.getTime(), date, "stderr", "info");
|
||||
this._totalSkipped = totalSkipped;
|
||||
this.lastSkipped = lastSkipped;
|
||||
}
|
||||
@@ -148,6 +161,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
|
||||
if (isObject(event.m)) {
|
||||
return new ComplexLogEntry(
|
||||
event.m,
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
@@ -156,6 +170,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
|
||||
} else {
|
||||
return new SimpleLogEntry(
|
||||
event.m,
|
||||
event.c,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
export class Stack {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly containers: Container[],
|
||||
public readonly services: Service[],
|
||||
) {
|
||||
for (const service of services) {
|
||||
service.stack = this;
|
||||
}
|
||||
}
|
||||
|
||||
get updatedAt() {
|
||||
return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0));
|
||||
}
|
||||
}
|
||||
|
||||
export class Service {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly containers: Container[],
|
||||
) {}
|
||||
|
||||
stack?: Stack;
|
||||
|
||||
get updatedAt() {
|
||||
return this.containers.map((c) => c.created).reduce((acc, date) => (date > acc ? date : acc), new Date(0));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { type App } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import pages from "~pages";
|
||||
import { routes } from "vue-router/auto-routes";
|
||||
import { setupLayouts } from "virtual:generated-layouts";
|
||||
|
||||
const routes = setupLayouts(pages);
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(withBase("/")),
|
||||
routes,
|
||||
routes: setupLayouts(routes),
|
||||
});
|
||||
|
||||
export const install = (app: App) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<page-with-links>
|
||||
<PageWithLinks>
|
||||
<div class="hero min-h-screen bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</page-with-links>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<search></search>
|
||||
<log-container
|
||||
:id="id"
|
||||
:show-title="true"
|
||||
:scrollable="activeContainers.length > 0"
|
||||
v-if="currentContainer"
|
||||
></log-container>
|
||||
<Search />
|
||||
<ContainerLog :id="id" :show-title="true" :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
|
||||
<div v-else-if="ready" class="hero min-h-screen bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
@@ -16,12 +11,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import search from "@/components/Search.vue";
|
||||
const store = useContainerStore();
|
||||
const { id } = defineProps<{ id: string }>();
|
||||
const route = useRoute("/container/[id]");
|
||||
const id = toRef(() => route.params.id);
|
||||
|
||||
const currentContainer = store.currentContainer($$(id));
|
||||
const { activeContainers, ready } = storeToRefs(store);
|
||||
const containerStore = useContainerStore();
|
||||
const currentContainer = containerStore.currentContainer(id);
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
@@ -33,3 +31,7 @@ watchEffect(() => {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
containerMode: true
|
||||
</route>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<Search />
|
||||
<GroupedLog :name="route.params.name" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/group/[name]");
|
||||
|
||||
const swarmStore = useSwarmStore();
|
||||
const { customGroups } = storeToRefs(swarmStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const group = computed(() => customGroups.value.find((g) => g.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (group.value?.name) {
|
||||
setTitle(group.value.name + " group");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
swarmMode: true
|
||||
</route>
|
||||
+7
-44
@@ -1,68 +1,31 @@
|
||||
<template>
|
||||
<page-with-links class="gap-16">
|
||||
<PageWithLinks>
|
||||
<section>
|
||||
<div class="stats grid bg-base-lighter shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ runningContainers.length }} / {{ containers.length }}</div>
|
||||
<div class="stat-title">{{ $t("label.running") }} / {{ $t("label.total-containers") }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ totalCpu.toFixed(0) }}%</div>
|
||||
<div class="stat-title">{{ $t("label.total-cpu-usage") }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ formatBytes(totalMem) }}</div>
|
||||
<div class="stat-title">{{ $t("label.total-mem-usage") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ version }}</div>
|
||||
<div class="stat-title">{{ $t("label.dozzle-version") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<HostList />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<container-table :containers="runningContainers"></container-table>
|
||||
<ContainerTable :containers="runningContainers"></ContainerTable>
|
||||
</section>
|
||||
</page-with-links>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { version } = config;
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
ready: Ref<boolean>;
|
||||
};
|
||||
|
||||
const mostRecentContainers = $computed(() => containers.value.toSorted((a, b) => +b.created - +a.created));
|
||||
const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running"));
|
||||
|
||||
let totalCpu = $ref(0);
|
||||
useIntervalFn(
|
||||
() => {
|
||||
totalCpu = runningContainers.reduce((acc, c) => acc + c.stat.cpu, 0);
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let totalMem = $ref(0);
|
||||
useIntervalFn(
|
||||
() => {
|
||||
totalMem = runningContainers.reduce((acc, c) => acc + c.stat.memoryUsage, 0);
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
setTitle(t("title.dashboard", { count: runningContainers.length }));
|
||||
setTitle(t("title.dashboard", { count: runningContainers.value.length }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Password</span>
|
||||
<span class="label-text">{{ $t("label.password") }}</span>
|
||||
</label>
|
||||
<input
|
||||
class="input input-bordered"
|
||||
@@ -50,8 +50,7 @@ let form: HTMLFormElement | undefined = $ref();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
async function onLogin() {
|
||||
const url = config.authProvider === "simple" ? "/api/token" : "/api/validateCredentials";
|
||||
const response = await fetch(withBase(url), {
|
||||
const response = await fetch(withBase("/api/token"), {
|
||||
body: new FormData(form),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<Search />
|
||||
<MultiContainerLog :ids="ids" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const route = useRoute("/merged/[name]");
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const ids = toRef(() => route.query.id as string[]);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
setTitle("Merged Logs");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
containerMode: true
|
||||
</route>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<Search />
|
||||
<ServiceLog :name="route.params.name" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/service/[name]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const stackStore = useSwarmStore();
|
||||
const { services } = storeToRefs(stackStore);
|
||||
const service = computed(() => services.value.find((s) => s.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (service.value?.name) {
|
||||
setTitle(service.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
swarmMode: true
|
||||
</route>
|
||||
+24
-21
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<page-with-links>
|
||||
<PageWithLinks>
|
||||
<section>
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("settings.about") }}</h2>
|
||||
@@ -21,17 +21,17 @@
|
||||
|
||||
<section class="grid-cols-2 gap-4 @3xl:grid">
|
||||
<div class="flex flex-col gap-2 text-balance @3xl:pr-8">
|
||||
<toggle v-model="compact"> {{ $t("settings.compact") }} </toggle>
|
||||
<Toggle v-model="compact"> {{ $t("settings.compact") }} </Toggle>
|
||||
|
||||
<toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </toggle>
|
||||
<Toggle v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </Toggle>
|
||||
|
||||
<toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</toggle>
|
||||
<Toggle v-model="showTimestamp">{{ $t("settings.show-timesamps") }}</Toggle>
|
||||
|
||||
<toggle v-model="showStd">{{ $t("settings.show-std") }}</toggle>
|
||||
<Toggle v-model="showStd">{{ $t("settings.show-std") }}</Toggle>
|
||||
|
||||
<toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</toggle>
|
||||
<Toggle v-model="softWrap">{{ $t("settings.soft-wrap") }}</Toggle>
|
||||
|
||||
<labeled-input>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.locale") }}
|
||||
</template>
|
||||
@@ -44,9 +44,9 @@
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</labeled-input>
|
||||
</LabeledInput>
|
||||
|
||||
<labeled-input>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.datetime-format") }}
|
||||
</template>
|
||||
@@ -72,9 +72,9 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</labeled-input>
|
||||
</LabeledInput>
|
||||
|
||||
<labeled-input>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.font-size") }}
|
||||
</template>
|
||||
@@ -88,9 +88,9 @@
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</labeled-input>
|
||||
</LabeledInput>
|
||||
|
||||
<labeled-input>
|
||||
<LabeledInput>
|
||||
<template #label>
|
||||
{{ $t("settings.color-scheme") }}
|
||||
</template>
|
||||
@@ -104,12 +104,13 @@
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</labeled-input>
|
||||
</LabeledInput>
|
||||
</div>
|
||||
<log-viewer
|
||||
<LogList
|
||||
:messages="fakeMessages"
|
||||
:visible-keys="keys"
|
||||
:last-selected-item="undefined"
|
||||
:show-container-name="false"
|
||||
class="hidden overflow-hidden rounded-lg border border-base-content/50 shadow @3xl:block"
|
||||
/>
|
||||
</section>
|
||||
@@ -133,7 +134,7 @@
|
||||
<toggle v-model="automaticRedirect">{{ $t("settings.automatic-redirect") }}</toggle>
|
||||
</div>
|
||||
</section>
|
||||
</page-with-links>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -168,11 +169,11 @@ const hoursAgo = (hours: number) => {
|
||||
};
|
||||
|
||||
const fakeMessages = [
|
||||
new SimpleLogEntry("This is a preview of the logs", 1, hoursAgo(16), "info", undefined, "stdout"),
|
||||
new SimpleLogEntry("A warning log looks like this", 2, hoursAgo(12), "warn", undefined, "stdout"),
|
||||
new SimpleLogEntry("This is a multi line error message", 3, hoursAgo(7), "error", "start", "stderr"),
|
||||
new SimpleLogEntry("with a second line", 4, hoursAgo(2), "error", "middle", "stderr"),
|
||||
new SimpleLogEntry("and finally third line.", 5, new Date(), "error", "end", "stderr"),
|
||||
new SimpleLogEntry("This is a preview of the logs", "123", 1, hoursAgo(16), "info", undefined, "stdout"),
|
||||
new SimpleLogEntry("A warning log looks like this", "123", 2, hoursAgo(12), "warn", undefined, "stdout"),
|
||||
new SimpleLogEntry("This is a multi line error message", "123", 3, hoursAgo(7), "error", "start", "stderr"),
|
||||
new SimpleLogEntry("with a second line", "123", 4, hoursAgo(2), "error", "middle", "stderr"),
|
||||
new SimpleLogEntry("and finally third line.", "123", 5, new Date(), "error", "end", "stderr"),
|
||||
new ComplexLogEntry(
|
||||
{
|
||||
message: "This is a complex log entry as json",
|
||||
@@ -181,6 +182,7 @@ const fakeMessages = [
|
||||
key2: "value2",
|
||||
},
|
||||
},
|
||||
"123",
|
||||
6,
|
||||
new Date(),
|
||||
"info",
|
||||
@@ -189,6 +191,7 @@ const fakeMessages = [
|
||||
),
|
||||
new SimpleLogEntry(
|
||||
"This is a very very long message which would wrap by default. Disabling soft wraps would disable this. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
|
||||
"123",
|
||||
7,
|
||||
new Date(),
|
||||
"debug",
|
||||
|
||||
@@ -10,14 +10,14 @@ watch(visibleContainers, (newValue) => {
|
||||
if (route.query.name) {
|
||||
const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name);
|
||||
if (container) {
|
||||
router.push({ name: "container-id", params: { id: container.id } });
|
||||
router.push({ name: "/container/[id]", params: { id: container.id } });
|
||||
} else {
|
||||
console.error(`No containers found matching name=${route.query.name}. Redirecting to /`);
|
||||
router.push({ name: "index" });
|
||||
router.push({ name: "/" });
|
||||
}
|
||||
} else {
|
||||
console.error(`Expection query parameter name to be set. Redirecting to /`);
|
||||
router.push({ name: "index" });
|
||||
router.push({ name: "/" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<Search />
|
||||
<StackLog :name="route.params.name" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/stack/[name]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const stackStore = useSwarmStore();
|
||||
const { stacks } = storeToRefs(stackStore);
|
||||
const stack = computed(() => stacks.value.find((s) => s.name === route.params.name));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (stack.value?.name) {
|
||||
setTitle(stack.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
swarmMode: true
|
||||
</route>
|
||||
@@ -1,13 +1,16 @@
|
||||
import { type Settings } from "@/stores/settings";
|
||||
import { Host } from "@/stores/hosts";
|
||||
|
||||
const text = document.querySelector("script#config__json")?.textContent || "{}";
|
||||
|
||||
type HostWithoutAvailable = Omit<Host, "available">;
|
||||
|
||||
export interface Config {
|
||||
version: string;
|
||||
base: string;
|
||||
maxLogs: number;
|
||||
hostname: string;
|
||||
hosts: { name: string; id: string }[];
|
||||
hosts: HostWithoutAvailable[];
|
||||
authProvider: "simple" | "none" | "forward-proxy";
|
||||
enableActions: boolean;
|
||||
user?: {
|
||||
@@ -23,12 +26,15 @@ export interface Profile {
|
||||
pinned?: Set<string>;
|
||||
visibleKeys?: { [key: string]: string[][] };
|
||||
releaseSeen?: string;
|
||||
collapsedGroups?: Set<string>;
|
||||
}
|
||||
|
||||
const pageConfig = JSON.parse(text);
|
||||
|
||||
const config: Config = {
|
||||
maxLogs: 600,
|
||||
version: "v0.0.0",
|
||||
hosts: [],
|
||||
...pageConfig,
|
||||
};
|
||||
|
||||
|
||||
+26
-13
@@ -4,13 +4,14 @@ import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Cont
|
||||
import { Container } from "@/models/Container";
|
||||
import i18n from "@/modules/i18n";
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { showToast, removeToast } = useToast();
|
||||
const { markHostAvailable } = useHosts();
|
||||
// @ts-ignore
|
||||
const { t } = i18n.global;
|
||||
|
||||
export const useContainerStore = defineStore("container", () => {
|
||||
const containers: Ref<Container[]> = ref([]);
|
||||
const activeContainerIds: Ref<string[]> = ref([]);
|
||||
|
||||
let es: EventSource | null = null;
|
||||
const ready = ref(false);
|
||||
|
||||
@@ -29,14 +30,12 @@ export const useContainerStore = defineStore("container", () => {
|
||||
return containers.value.filter(filter);
|
||||
});
|
||||
|
||||
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
|
||||
|
||||
function connect() {
|
||||
es?.close();
|
||||
ready.value = false;
|
||||
es = new EventSource(withBase("/api/events/stream"));
|
||||
es.addEventListener("error", (e) => {
|
||||
if (es?.readyState === EventSource.CLOSED) {
|
||||
if (es?.readyState === EventSource.CONNECTING) {
|
||||
showToast(
|
||||
{
|
||||
id: "events-stream",
|
||||
@@ -68,6 +67,11 @@ export const useContainerStore = defineStore("container", () => {
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("host-unavailable", (e) => {
|
||||
const hostId = (e as MessageEvent).data;
|
||||
markHostAvailable(hostId, false);
|
||||
});
|
||||
|
||||
es.addEventListener("container-health", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; health: ContainerHealth };
|
||||
const container = allContainersById.value[event.actorId];
|
||||
@@ -77,6 +81,7 @@ export const useContainerStore = defineStore("container", () => {
|
||||
});
|
||||
|
||||
es.onopen = () => {
|
||||
removeToast("events-stream");
|
||||
if (containers.value.length > 0) {
|
||||
containers.value = [];
|
||||
}
|
||||
@@ -119,7 +124,7 @@ export const useContainerStore = defineStore("container", () => {
|
||||
...newContainers.map((c) => {
|
||||
return new Container(
|
||||
c.id,
|
||||
new Date(c.created * 1000),
|
||||
new Date(c.created),
|
||||
c.image,
|
||||
c.name,
|
||||
c.command,
|
||||
@@ -128,6 +133,7 @@ export const useContainerStore = defineStore("container", () => {
|
||||
c.status,
|
||||
c.state,
|
||||
c.stats,
|
||||
c.group,
|
||||
c.health,
|
||||
);
|
||||
}),
|
||||
@@ -135,19 +141,26 @@ export const useContainerStore = defineStore("container", () => {
|
||||
};
|
||||
|
||||
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
|
||||
const appendActiveContainer = ({ id }: { id: string }) => activeContainerIds.value.push(id);
|
||||
const removeActiveContainer = ({ id }: { id: string }) =>
|
||||
activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1);
|
||||
|
||||
const containerNames = computed(() =>
|
||||
containers.value.reduce(
|
||||
(acc, container) => {
|
||||
acc[container.id] = container.name;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
|
||||
const findContainerById = (id: string) => allContainersById.value[id];
|
||||
|
||||
return {
|
||||
containers,
|
||||
activeContainerIds,
|
||||
allContainersById,
|
||||
visibleContainers,
|
||||
activeContainers,
|
||||
currentContainer,
|
||||
appendActiveContainer,
|
||||
removeActiveContainer,
|
||||
findContainerById,
|
||||
containerNames,
|
||||
ready,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type Host = {
|
||||
name: string;
|
||||
id: string;
|
||||
nCPU: number;
|
||||
memTotal: number;
|
||||
available: boolean;
|
||||
};
|
||||
const hosts = computed(() =>
|
||||
config.hosts.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = { ...item, available: true };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Host>,
|
||||
),
|
||||
);
|
||||
|
||||
const markHostAvailable = (id: string, available: boolean) => {
|
||||
hosts.value[id].available = available;
|
||||
};
|
||||
|
||||
export function useHosts() {
|
||||
return {
|
||||
hosts,
|
||||
markHostAvailable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
import { Ref } from "vue";
|
||||
|
||||
export const usePinnedLogsStore = defineStore("pinnedLogs", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { allContainersById } = storeToRefs(containerStore);
|
||||
const pinnedContainerIds: Ref<string[]> = ref([]);
|
||||
|
||||
const pinnedLogs = computed(() => pinnedContainerIds.value.map((id) => allContainersById.value[id]));
|
||||
|
||||
const pinContainer = ({ id }: { id: string }) => pinnedContainerIds.value.push(id);
|
||||
const unPinContainer = ({ id }: { id: string }) =>
|
||||
pinnedContainerIds.value.splice(pinnedContainerIds.value.indexOf(id), 1);
|
||||
|
||||
const isPinned = ({ id }: { id: string }) => pinnedContainerIds.value.includes(id);
|
||||
|
||||
return {
|
||||
pinnedLogs,
|
||||
isPinned,
|
||||
pinContainer,
|
||||
unPinContainer,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(usePinnedLogsStore, import.meta.hot));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
import { Service, Stack } from "@/models/Stack";
|
||||
|
||||
export const useSwarmStore = defineStore("swarm", () => {
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
const stacks = computed(() => {
|
||||
const namespaced: Record<string, Container[]> = {};
|
||||
for (const item of runningContainers.value) {
|
||||
const namespace = item.namespace;
|
||||
if (namespace === undefined) continue;
|
||||
namespaced[namespace] ||= [];
|
||||
namespaced[namespace].push(item);
|
||||
}
|
||||
|
||||
const newStacks: Stack[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(namespaced)) {
|
||||
const services: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of containers) {
|
||||
const service = container.labels["com.docker.swarm.service.name"];
|
||||
|
||||
if (service === undefined) continue;
|
||||
services[service] ||= [];
|
||||
services[service].push(container);
|
||||
}
|
||||
|
||||
const newServices: Service[] = [];
|
||||
|
||||
for (const [name, containers] of Object.entries(services)) {
|
||||
newServices.push(new Service(name, containers));
|
||||
}
|
||||
|
||||
if (newServices.length === 0) continue;
|
||||
|
||||
newStacks.push(new Stack(name, containers, newServices));
|
||||
}
|
||||
return newStacks;
|
||||
});
|
||||
|
||||
const services = computed(() => {
|
||||
const services: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const service = container.labels["com.docker.swarm.service.name"];
|
||||
const namespace = container.namespace;
|
||||
|
||||
if (service === undefined) continue;
|
||||
if (namespace) continue; // skip containers that already have a stack
|
||||
services[service] ||= [];
|
||||
services[service].push(container);
|
||||
}
|
||||
|
||||
const serviceWithStack = stacks.value.flatMap((stack) => stack.services);
|
||||
|
||||
const servicesWithoutStack = Object.entries(services).map(([name, containers]) => new Service(name, containers));
|
||||
|
||||
return [...serviceWithStack, ...servicesWithoutStack];
|
||||
});
|
||||
|
||||
const customGroups = computed(() => {
|
||||
const grouped: Record<string, Container[]> = {};
|
||||
|
||||
for (const container of runningContainers.value) {
|
||||
const group = container.customGroup;
|
||||
if (group === undefined) continue;
|
||||
grouped[group] ||= [];
|
||||
grouped[group].push(container);
|
||||
}
|
||||
|
||||
return Object.entries(grouped).map(([name, containers]) => new GroupedContainers(name, containers));
|
||||
});
|
||||
|
||||
return {
|
||||
stacks,
|
||||
services,
|
||||
customGroups,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useSwarmStore, import.meta.hot));
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
/**
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
|
||||
'/container/[id]': RouteRecordInfo<'/container/[id]', '/container/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'/group/[name]': RouteRecordInfo<'/group/[name]', '/group/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
|
||||
'/merged/[name]': RouteRecordInfo<'/merged/[name]', '/merged/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
'/service/[name]': RouteRecordInfo<'/service/[name]', '/service/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
'/settings': RouteRecordInfo<'/settings', '/settings', Record<never, never>, Record<never, never>>,
|
||||
'/show': RouteRecordInfo<'/show', '/show', Record<never, never>, Record<never, never>>,
|
||||
'/stack/[name]': RouteRecordInfo<'/stack/[name]', '/stack/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user