mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 23:32:08 +00:00
Compare commits
430 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 485c2324af | |||
| 48d9958e0c | |||
| ea2132efc9 | |||
| 7a6914c6cf | |||
| da66540952 | |||
| f863fc1bd3 | |||
| 91b9c26c3a | |||
| ae483639bb | |||
| 38383e8924 | |||
| c66bbc520c | |||
| 7c2b7a5dcb | |||
| 0c96821f15 | |||
| d1a225adc1 | |||
| 3a7efe9ada | |||
| 3e3bb02b5f | |||
| c3ee2da33c | |||
| a488e2e7ee | |||
| 46fdb88b81 | |||
| c88429c854 | |||
| 9e2d053ec8 | |||
| 97ece4c5ac | |||
| 4da50bbbd9 | |||
| ababa789c3 | |||
| 60100dfdbb | |||
| 0370759f69 | |||
| 1b01710aa8 | |||
| 0ffdd53568 | |||
| d9c9d20fff | |||
| e0797d7675 | |||
| 25d8ba413a | |||
| 80bbb317aa | |||
| c1c61c0ce6 | |||
| 792a9f1187 | |||
| a3a6c03f11 | |||
| 46b4fc0994 | |||
| 4878144592 | |||
| 99f203a23e | |||
| 211a442147 | |||
| 02aafac2a9 | |||
| 3b260bef27 | |||
| fe0902e62a | |||
| e4a42125c5 | |||
| db2b7b12a3 | |||
| d6b262aead | |||
| 917489838d | |||
| cefee41857 | |||
| 8455e2743d | |||
| a5cc1f7ca0 | |||
| 1ffc338720 | |||
| 19bb865913 | |||
| bb4dca2386 | |||
| 808439365f | |||
| c1038c0a9e | |||
| 15e410a874 | |||
| 5049a53a95 | |||
| c86d108997 | |||
| 604c4b9b16 | |||
| 64b03d7b92 | |||
| a533533d4c | |||
| 2d50820932 | |||
| 3190868268 | |||
| 2c031da510 | |||
| 58ececc42a | |||
| 7be55cbc08 | |||
| 90b90ceea4 | |||
| bc842389f9 | |||
| cf9bf4dcd5 | |||
| 3eec803e45 | |||
| e332b4225b | |||
| 538d16a5b7 | |||
| 561081ebf0 | |||
| c3d2e7ac09 | |||
| 90808aff68 | |||
| 77bfb43b6d | |||
| 49881c2dfe | |||
| 3f901769cb | |||
| 26f7704edb | |||
| 3686f5c54a | |||
| aabd9dd994 | |||
| 52e0be0ac6 | |||
| bb0ffbfa04 | |||
| 4022b510e2 | |||
| 21fb03c9d0 | |||
| 392f480c76 | |||
| c9418d4b32 | |||
| 14b14e6210 | |||
| 3d5b279a1b | |||
| aca3e3a219 | |||
| 241d349d39 | |||
| b098dcaef0 | |||
| 7cf67eb99a | |||
| 869d5e5002 | |||
| 392dfafc3d | |||
| 21fe5b84f2 | |||
| 65fa04c877 | |||
| 3a352a7320 | |||
| 3bdc9c04f8 | |||
| 6e3c811974 | |||
| ddff9e96a2 | |||
| 1daf813e1b | |||
| 09a169851b | |||
| 1d5ddee1ae | |||
| b7e9871382 | |||
| 9e028b9916 | |||
| 78c0e5fd7f | |||
| 4861503488 | |||
| 115b0e475d | |||
| 6d928815df | |||
| 2a70d0b458 | |||
| 8c9c9a83af | |||
| 571de4084e | |||
| 5abf7e4a9d | |||
| d580e9447b | |||
| 877faaa740 | |||
| 1d4698d77a | |||
| 13da2a4222 | |||
| 209ce312d4 | |||
| 6da36c4c0d | |||
| d9d93a8147 | |||
| eba87fa49e | |||
| 7ae409981c | |||
| 20fb7984d2 | |||
| 17c15b65a2 | |||
| 9ce736a448 | |||
| 10c95124e4 | |||
| 5e02142f77 | |||
| f9585cc656 | |||
| 6da7f6dffd | |||
| 2de739141d | |||
| 585e1edfee | |||
| bf81852003 | |||
| 5f73b41c57 | |||
| 9f7b17f4ec | |||
| 6c292be943 | |||
| 21ae6eda8b | |||
| b732844e7b | |||
| 1b38f37ac2 | |||
| c20ce29f5c | |||
| 47e64bb57a | |||
| b9a5262257 | |||
| 47204b33c4 | |||
| 162f9ea580 | |||
| 2381a8b8a3 | |||
| 8fbbfaf043 | |||
| 3d077b9503 | |||
| 68ea86c55b | |||
| ad5c1c4452 | |||
| 0efd98e999 | |||
| ff72431a72 | |||
| 6d2ed53629 | |||
| 992e1a27ae | |||
| 2378a1f115 | |||
| 3b122564d9 | |||
| a783f7060c | |||
| 76e5c63436 | |||
| 5a17a07b8f | |||
| d1fc9b750b | |||
| 87a59939dd | |||
| 3a159cb89b | |||
| 84ec46c400 | |||
| 0c4ca6d605 | |||
| db5d36c89b | |||
| 8cdacf608a | |||
| 78487a553c | |||
| 8da67482d8 | |||
| 60914f3208 | |||
| 0dc2a391df | |||
| 4cfd3d0cfa | |||
| 9752504097 | |||
| f08f2455de | |||
| b2ea1c592a | |||
| 6f89a1e70b | |||
| 93ce48ebf3 | |||
| 0412581b6f | |||
| 285d0ef0f4 | |||
| be313a68a3 | |||
| 14ddd1c4e8 | |||
| 368646a7a3 | |||
| 691a89660e | |||
| d03844b488 | |||
| 3f860805f5 | |||
| e8eef82b1c | |||
| e7e52cbe80 | |||
| cd9409f7e8 | |||
| d455c3eed8 | |||
| 35fd47d157 | |||
| 58d6848084 | |||
| 11e1285158 | |||
| 43e3f4cd9d | |||
| 5b822bdcfb | |||
| 0f1a916ff5 | |||
| 5766410892 | |||
| 7c061a5ef1 | |||
| 05f55538b4 | |||
| e8752c2e25 | |||
| 3cb7418ede | |||
| 37eccc5724 | |||
| b29e77e05d | |||
| a389c31b68 | |||
| 45b15ccc1b | |||
| 45ac8740f0 | |||
| 2f0dd4f36f | |||
| 19ebbccc29 | |||
| 90459ff3d5 | |||
| 5d57439d3d | |||
| 5c33f4437e | |||
| cbbe5f7571 | |||
| d79c517425 | |||
| 130defa367 | |||
| f8e7dafd8a | |||
| 81f24d1c26 | |||
| fdc3f06402 | |||
| a1a9031cda | |||
| 151a3efb16 | |||
| 42cc254eb7 | |||
| 917c837e90 | |||
| 756b212826 | |||
| dae2877bdb | |||
| 66cc3ddec8 | |||
| cd4e233d87 | |||
| 1b82929bc6 | |||
| e99107da90 | |||
| efa95078ac | |||
| 40015f7385 | |||
| 46fb09e2f7 | |||
| cc620ad429 | |||
| 8c6c6882cd | |||
| 64c23bc1db | |||
| 7f12f01267 | |||
| 0f9071944f | |||
| 291d4f7f37 | |||
| 2bf811d632 | |||
| 2a3f77be48 | |||
| 9518114816 | |||
| 65ef1d3c68 | |||
| f4f61a6a75 | |||
| cc76c00933 | |||
| 08e5deba7e | |||
| 05e01852ce | |||
| 9cff809a8e | |||
| d93efedc11 | |||
| 984452c181 | |||
| 471cc19132 | |||
| 7ec053e893 | |||
| af0a8f0e2d | |||
| 52de74e595 | |||
| 4b19d13d02 | |||
| 5a532b50e3 | |||
| 91d63882e0 | |||
| a98b1059d9 | |||
| 0d51523d80 | |||
| 1a69694c16 | |||
| e5c1f884a9 | |||
| d34cf32cce | |||
| fc2a2766f7 | |||
| 4fa04d0176 | |||
| 86522ccf77 | |||
| 23ba1f6603 | |||
| 3adf56018a | |||
| d02fbae4a9 | |||
| 6479bbba10 | |||
| bd0a81f332 | |||
| f2e56f79b7 | |||
| d312871d89 | |||
| 5a5a0d329b | |||
| 2f382ba34e | |||
| 92c60b29bd | |||
| 85a304b2e8 | |||
| d3e573d03b | |||
| 9f2d172277 | |||
| 55baf9e12c | |||
| d2e1b26fb4 | |||
| 864af3c24d | |||
| c0b40da585 | |||
| 242c3e74dc | |||
| 9a73d18da6 | |||
| 74b5adad00 | |||
| a62cef7e25 | |||
| 668a0d9b19 | |||
| 39be1ddf69 | |||
| 672a5f8c73 | |||
| 289fc661f0 | |||
| 9b0e731f90 | |||
| 7ce5b9514d | |||
| f50d45dcb2 | |||
| 2081335867 | |||
| 69d380e5bd | |||
| 8400319a85 | |||
| b7bd763b54 | |||
| eefa3532c1 | |||
| 441d2d1c7b | |||
| 41188947a9 | |||
| 877f53462c | |||
| c2e3879c1f | |||
| 327ae70428 | |||
| be197ea19d | |||
| 749b19f2c1 | |||
| ec5ab3a0fe | |||
| 0ab5ad2c5d | |||
| 1448371c23 | |||
| daee5b7dc4 | |||
| 08a7d39e43 | |||
| fd13320152 | |||
| 823959f8bb | |||
| a99d711b78 | |||
| 75f089b770 | |||
| 508408c0af | |||
| 5b4ca4d766 | |||
| 036bee5621 | |||
| c36cf72506 | |||
| 129f80e30e | |||
| 3f3346d003 | |||
| ef6e0569b7 | |||
| 4c1f5b4062 | |||
| 9d244cf26b | |||
| 002dbea904 | |||
| d41f3f192a | |||
| 75fa2d1a22 | |||
| c3f6ff9c56 | |||
| 95de5a8504 | |||
| 50855a8700 | |||
| bd7f6bc886 | |||
| 7ee260311e | |||
| 011294d198 | |||
| 7ccc312190 | |||
| 484260d492 | |||
| cbdd0cc961 | |||
| cb808df400 | |||
| d4fa7ace90 | |||
| 1144bdfbd2 | |||
| 9a296ca504 | |||
| 80d502eb29 | |||
| 56a2ab11a9 | |||
| 675a4ab334 | |||
| 197ab6d4da | |||
| 9296b1f7cf | |||
| dcf261f418 | |||
| c032f788fb | |||
| b3177e95d9 | |||
| 48e509e19f | |||
| d7662c32d0 | |||
| 3487d6cb38 | |||
| feee4a07b4 | |||
| ea006a0d54 | |||
| d99964bbe7 | |||
| f3cfe4f84d | |||
| 8408f887b6 | |||
| 16d0dc6671 | |||
| 596861412d | |||
| 06136e716a | |||
| 5db3448207 | |||
| c57d6ed4f0 | |||
| b85eeeddd4 | |||
| 964429a95e | |||
| e22d54cf74 | |||
| 7bf331cf6e | |||
| d529ed7855 | |||
| 46d124b11a | |||
| d09aa5d481 | |||
| cc77c16f92 | |||
| 63be3bf2d8 | |||
| ec29c78052 | |||
| df3f04dd50 | |||
| 7af7956b7f | |||
| d21473fe0b | |||
| 4093512497 | |||
| 0ef6252158 | |||
| 5947338966 | |||
| db5e760ae3 | |||
| 1ab4464f9a | |||
| 45ea4ac095 | |||
| c6339effd1 | |||
| 4e217f4e3a | |||
| c58f3b4254 | |||
| a7680940ab | |||
| cce2e8a8b5 | |||
| 8513824fb4 | |||
| 00da15c2fc | |||
| c1d31313b2 | |||
| 6f7c1d8ba6 | |||
| f1982c094d | |||
| cd63b5ded8 | |||
| 96fbee9ece | |||
| 36a5d7e6b9 | |||
| 520d82246f | |||
| ddfd00aaed | |||
| f3ab541ad1 | |||
| 4026e927f5 | |||
| b1643d490c | |||
| eab380b70d | |||
| 070f1c7bdb | |||
| 8b1adb51c8 | |||
| 46465356ab | |||
| ce096983ca | |||
| ddd3a80ddb | |||
| aeb1fd96e2 | |||
| dc9f402a1d | |||
| 2c2e3d5790 | |||
| 5e33094517 | |||
| 853a5d4f0d | |||
| 8ea5876615 | |||
| c4aacb211f | |||
| 8cf0912af0 | |||
| cedd112f6b | |||
| 01cf239569 | |||
| f4f14b900c | |||
| 6722a0129e | |||
| 277beb940b | |||
| 2d62257a33 | |||
| 5a890cbb94 | |||
| 39b3b1b59d | |||
| 567337649e | |||
| 423e4e11a3 | |||
| 049214d529 | |||
| d11f997999 | |||
| 1e5264a00b | |||
| 157b2cb304 | |||
| dc18e0f4a4 | |||
| 9bbe35e7fd | |||
| add108dc04 | |||
| 45ec05419d | |||
| 00f0cb606a | |||
| 896c402adc | |||
| f9f0f8f1e0 | |||
| 3b014dc0c7 | |||
| 3dcd5b3b78 | |||
| ea8a9401ee | |||
| 36e5abfa64 | |||
| b0a4a4c6e4 | |||
| 96366db7da |
@@ -0,0 +1,57 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dozzle"
|
||||
cmd = "go build -race -o ./tmp/dozzle ."
|
||||
delay = 1000
|
||||
exclude_dir = [
|
||||
"assets",
|
||||
"tmp",
|
||||
"data",
|
||||
"node_modules",
|
||||
"docs",
|
||||
"test-results",
|
||||
"dist",
|
||||
"e2e",
|
||||
"examples",
|
||||
]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "2s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = true
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -23,12 +23,13 @@ body:
|
||||
- Agents
|
||||
- Remote Hosts
|
||||
- Swarm Mode
|
||||
- Kubernetes
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: 📦 Dozzle version
|
||||
description: Can be found in logs or settings page.
|
||||
description: Run `docker run amir20/dozzle --version` or `curl http://<host>/api/version`.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: amirraminfar
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:base", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump",
|
||||
"ignoreDeps": ["@duckdb/duckdb-wasm"]
|
||||
"ignoreDeps": []
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22.x
|
||||
go-version: 1.24.1
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,8 +69,9 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v5
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
source: .
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha
|
||||
@@ -89,25 +90,38 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.3.0
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
images: |
|
||||
amir20/dozzle
|
||||
ghcr.io/amir20/dozzle
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
- 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@v6.8.0
|
||||
uses: docker/build-push-action@v6.15.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
|
||||
@@ -13,25 +13,33 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.3.0
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
images: |
|
||||
amir20/dozzle
|
||||
ghcr.io/amir20/dozzle
|
||||
- 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@v6.8.0
|
||||
uses: docker/build-push-action@v6.15.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23.1
|
||||
go-version: "1.24.1"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -68,6 +68,19 @@ jobs:
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test
|
||||
staticcheck:
|
||||
runs-on: ubuntu-latest
|
||||
name: Go Staticcheck
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.1"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
- name: Stactic checker
|
||||
uses: dominikh/staticcheck-action@v1.3.1
|
||||
with:
|
||||
@@ -96,8 +109,9 @@ jobs:
|
||||
- name: Generate certs
|
||||
run: make shared_key.pem shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v5
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
source: .
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha
|
||||
|
||||
@@ -14,3 +14,4 @@ coverage.out
|
||||
/playwright/.cache/
|
||||
*.pem
|
||||
*.csr
|
||||
tmp
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
-r '\.(go)$' -R 'node_modules' -G '\*\_test.go' -s -- go run -race main.go --level debug agent
|
||||
@@ -1 +0,0 @@
|
||||
-r '\.(go)$' -R 'node_modules' -G '\*\_test.go' -s -- go run -race main.go --level debug
|
||||
Vendored
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
// 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
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"cSpell.words": ["healthcheck", "orderedmap", "Retriable", "stdcopy", "Warnf"],
|
||||
"editor.formatOnSave": true,
|
||||
"i18n-ally.extract.autoDetect": true
|
||||
}
|
||||
+6
-5
@@ -1,5 +1,5 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:22.9.0-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:23.10.0-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -22,13 +22,13 @@ COPY public ./public
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23.1-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
|
||||
|
||||
# install gRPC dependencies
|
||||
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
|
||||
&& 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
|
||||
&& 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
|
||||
|
||||
@@ -38,6 +38,7 @@ RUN go mod download
|
||||
|
||||
# Copy all other files
|
||||
COPY internal ./internal
|
||||
COPY types ./types
|
||||
COPY main.go ./
|
||||
COPY protos ./protos
|
||||
COPY shared_key.pem shared_cert.pem ./
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Amir Raminfar
|
||||
Copyright (c) 2025 Amir Raminfar
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -61,7 +61,7 @@ push: docker
|
||||
tools:
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
go install github.com/cespare/reflex@latest
|
||||
go install github.com/air-verse/air@latest
|
||||
|
||||
run: docker
|
||||
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest
|
||||
|
||||
@@ -65,7 +65,7 @@ Dozzle can be used to monitor multiple Docker hosts. You can run Dozzle in agent
|
||||
|
||||
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -p 7007:7007 amir20/dozzle:latest agent
|
||||
|
||||
See the [Agent Mode](https://dozzle.dev/guide/agent-mode) documentation for more details.
|
||||
See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more details.
|
||||
|
||||
## Technical Details
|
||||
|
||||
@@ -94,9 +94,15 @@ 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 -d -p 8080:8080 amir20/dozzle:latest
|
||||
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8080:8080 docker.io/amir20/dozzle:latest
|
||||
```
|
||||
|
||||
Additionally you have to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker by itself due to its daemonless architecture.
|
||||
|
||||
Under `/var/lib/docker` create a file named `engine-id`. On a system with Podman you will have to create the folder path as well. Inside the file place the UUID, for instance using `uuidgen > engine-id`. After that the file should have an identifier that looks like this: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
|
||||
|
||||
For more details check [Podman Infos](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it)
|
||||
|
||||
## Security
|
||||
|
||||
Dozzle supports file based authentication and forward proxy like [Authelia](https://www.authelia.com/). These are documented at https://dozzle.dev/guide/authentication.
|
||||
@@ -121,12 +127,14 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
|
||||
| `--auth-header-user` | `DOZZLE_AUTH_HEADER_USER` | `Remote-User` |
|
||||
| `--auth-header-email` | `DOZZLE_AUTH_HEADER_EMAIL` | `Remote-Email` |
|
||||
| `--auth-header-name` | `DOZZLE_AUTH_HEADER_NAME` | `Remote-Name` |
|
||||
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | false |
|
||||
| `--enable-actions` | `DOZZLE_ENABLE_ACTIONS` | `false` |
|
||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | `false` |
|
||||
| `--mode` | `DOZZLE_MODE` | `server` |
|
||||
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
|
||||
| `--remote-agent` | `DOZZLE_REMOTE_AGENT` | |
|
||||
| `--timeout` | `DOZZLE_TIMEOUT` | `10s` |
|
||||
| `--namespace` | `DOZZLE_NAMESPACE` | `""` |
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
+2
-1
@@ -15,10 +15,11 @@ watchEffect(() => {
|
||||
if (theme === "auto") {
|
||||
theme = mode.value;
|
||||
}
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme == "dark" ? "#121212" : "#3FA68F");
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
});
|
||||
</script>
|
||||
<style lang="postcss">
|
||||
<style>
|
||||
html.has-custom-scrollbars {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
Vendored
+34
-19
@@ -6,16 +6,10 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const $: typeof import('vue/macros')['$']
|
||||
const $$: typeof import('vue/macros')['$$']
|
||||
const $computed: typeof import('vue/macros')['$computed']
|
||||
const $customRef: typeof import('vue/macros')['$customRef']
|
||||
const $ref: typeof import('vue/macros')['$ref']
|
||||
const $shallowRef: typeof import('vue/macros')['$shallowRef']
|
||||
const $toRef: typeof import('vue/macros')['$toRef']
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings')['DEFAULT_SETTINGS']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const allLevels: typeof import('./composable/logContext')['allLevels']
|
||||
const arrayEquals: typeof import('./utils/index')['arrayEquals']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
@@ -37,6 +31,7 @@ declare global {
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
@@ -49,7 +44,6 @@ declare global {
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const drawerContext: typeof import('./composable/drawer')['drawerContext']
|
||||
const drwaer: typeof import('./composable/showLogDetails')['drwaer']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
@@ -65,7 +59,6 @@ declare global {
|
||||
const hashCode: typeof import('./utils/index')['hashCode']
|
||||
const hourStyle: typeof import('./stores/settings')['hourStyle']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const initDuckDB: typeof import('./composable/duckdb')['initDuckDB']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
@@ -95,6 +88,7 @@ declare global {
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
@@ -111,9 +105,7 @@ declare global {
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
|
||||
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideDetails: typeof import('./composable/showDetails')['provideDetails']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const provideLogDetails: typeof import('./composable/showLogDetails')['provideLogDetails']
|
||||
const provideLoggingContext: typeof import('./composable/logContext')['provideLoggingContext']
|
||||
const provideScrollContext: typeof import('./composable/scrollContext')['provideScrollContext']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
@@ -143,7 +135,6 @@ declare global {
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const showAllContainers: typeof import('./stores/settings')['showAllContainers']
|
||||
const showLogDetails: typeof import('./composable/showLogDetails')['showLogDetails']
|
||||
const showStd: typeof import('./stores/settings')['showStd']
|
||||
const showTimestamp: typeof import('./stores/settings')['showTimestamp']
|
||||
const size: typeof import('./stores/settings')['size']
|
||||
@@ -172,6 +163,7 @@ declare global {
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useAnnouncements: typeof import('./stores/announcements')['useAnnouncements']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
@@ -202,6 +194,7 @@ declare global {
|
||||
const useContainerActions: typeof import('./composable/containerActions')['useContainerActions']
|
||||
const useContainerStore: typeof import('./stores/container')['useContainerStore']
|
||||
const useContainerStream: typeof import('./composable/eventStreams')['useContainerStream']
|
||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
@@ -213,7 +206,6 @@ declare global {
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDetails: typeof import('./composable/showDetails')['useDetails']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
@@ -246,6 +238,7 @@ declare global {
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useHostStream: typeof import('./composable/eventStreams')['useHostStream']
|
||||
const useHosts: typeof import('./stores/hosts')['useHosts']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
@@ -258,8 +251,6 @@ declare global {
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useLogDetails: typeof import('./composable/showLogDetails')['useLogDetails']
|
||||
const useLogSearchContext: typeof import('./composable/logSearchContext')['useLogSearchContext']
|
||||
const useLoggingContext: typeof import('./composable/logContext')['useLoggingContext']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
@@ -294,14 +285,15 @@ declare global {
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useProfileStorage: typeof import('./composable/profileStorage')['useProfileStorage']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useReleases: typeof import('./stores/releases')['useReleases']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
@@ -381,9 +373,25 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
export type { LogStreamSource } from './composable/eventStreams'
|
||||
import('./composable/eventStreams')
|
||||
// @ts-ignore
|
||||
export type { Config, Profile } from './stores/config'
|
||||
import('./stores/config')
|
||||
// @ts-ignore
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
import('./stores/settings')
|
||||
}
|
||||
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
@@ -392,6 +400,7 @@ declare module 'vue' {
|
||||
readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./stores/settings')['DEFAULT_SETTINGS']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly allLevels: UnwrapRef<typeof import('./composable/logContext')['allLevels']>
|
||||
readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
@@ -413,6 +422,7 @@ declare module 'vue' {
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
@@ -469,6 +479,7 @@ declare module 'vue' {
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
|
||||
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
|
||||
@@ -543,6 +554,7 @@ declare module 'vue' {
|
||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||
readonly useAnnouncements: UnwrapRef<typeof import('./stores/announcements')['useAnnouncements']>
|
||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
|
||||
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
|
||||
@@ -573,6 +585,7 @@ declare module 'vue' {
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
readonly useContainerStream: UnwrapRef<typeof import('./composable/eventStreams')['useContainerStream']>
|
||||
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
@@ -616,6 +629,7 @@ declare module 'vue' {
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
|
||||
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
|
||||
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
|
||||
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
@@ -662,14 +676,15 @@ declare module 'vue' {
|
||||
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
|
||||
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
|
||||
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
||||
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
|
||||
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
||||
readonly useProfileStorage: UnwrapRef<typeof import('./composable/profileStorage')['useProfileStorage']>
|
||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||
readonly useReleases: UnwrapRef<typeof import('./stores/releases')['useReleases']>
|
||||
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
|
||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
|
||||
@@ -746,4 +761,4 @@ declare module 'vue' {
|
||||
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
|
||||
readonly withBase: UnwrapRef<typeof import('./stores/config')['withBase']>
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+17
-2
@@ -2,15 +2,18 @@
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Announcements: typeof import('./components/Announcements.vue')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
|
||||
'Carbon:information': typeof import('~icons/carbon/information')['default']
|
||||
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
|
||||
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
|
||||
'Carbon:play': typeof import('~icons/carbon/play')['default']
|
||||
'Carbon:restart': typeof import('~icons/carbon/restart')['default']
|
||||
@@ -18,6 +21,8 @@ declare module 'vue' {
|
||||
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
||||
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
|
||||
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
||||
Carousel: typeof import('./components/common/Carousel.vue')['default']
|
||||
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
|
||||
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
|
||||
'Cil:circle': typeof import('~icons/cil/circle')['default']
|
||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||
@@ -27,7 +32,6 @@ declare module 'vue' {
|
||||
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/ContainerViewer/ContainerTitle.vue')['default']
|
||||
@@ -38,10 +42,13 @@ declare module 'vue' {
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
|
||||
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
|
||||
HostList: typeof import('./components/HostList.vue')['default']
|
||||
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
|
||||
HostMenu: typeof import('./components/HostMenu.vue')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
|
||||
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
|
||||
@@ -49,11 +56,13 @@ declare module 'vue' {
|
||||
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
|
||||
LogItem: typeof import('./components/LogViewer/LogItem.vue')['default']
|
||||
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
|
||||
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
|
||||
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
|
||||
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
|
||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
@@ -65,6 +74,7 @@ declare module 'vue' {
|
||||
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
|
||||
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
|
||||
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
|
||||
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
|
||||
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
|
||||
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
|
||||
'Mdi:key': typeof import('~icons/mdi/key')['default']
|
||||
@@ -86,13 +96,15 @@ declare module 'vue' {
|
||||
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
|
||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:memory': typeof import('~icons/ph/memory')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
Releases: typeof import('./components/Releases.vue')['default']
|
||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||
@@ -105,12 +117,15 @@ declare module 'vue' {
|
||||
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
|
||||
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
|
||||
SlideTransition: typeof import('./components/common/SlideTransition.vue')['default']
|
||||
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
Terminal: typeof import('./components/Terminal.vue')['default']
|
||||
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
|
||||
ToastModal: typeof import('./components/common/ToastModal.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']
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<Dropdown class="dropdown-end" @closed="releaseSeen = mostRecent?.tag ?? config.version">
|
||||
<template #trigger>
|
||||
<mdi:announcement class="size-6 -rotate-12" />
|
||||
<template v-if="announcements.length > 0 && releaseSeen != mostRecent?.tag">
|
||||
<span class="bg-red absolute top-0 right-px size-2 animate-ping rounded-full opacity-75"></span>
|
||||
<span class="bg-red absolute top-0 right-px size-2 rounded-full"></span>
|
||||
</template>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-72">
|
||||
<ul class="space-y-4 p-2">
|
||||
<li v-for="release in announcements" v-if="announcements.length > 0">
|
||||
<template v-if="release.announcement">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<carbon:information class="text-info self-center" />
|
||||
<a
|
||||
:href="release.htmlUrl"
|
||||
class="link-primary text-lg font-bold"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
|
||||
</div>
|
||||
<div class="text-base-content/80 text-sm">
|
||||
{{ release.body }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<carbon:warning class="stroke-orange self-center" v-if="release.breaking > 0" />
|
||||
<a
|
||||
:href="release.htmlUrl"
|
||||
class="link-primary text-lg font-bold"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
|
||||
<Tag class="bg-red ml-auto px-1 py-1 text-xs" v-if="release.latest">
|
||||
{{ $t("releases.latest") }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-base-content/80 text-sm">
|
||||
{{ summary(release) }}
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
<li v-else>
|
||||
<div class="text-base-content/80 text-sm">
|
||||
{{ $t("releases.no_releases") }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAnnouncements } from "@/stores/announcements";
|
||||
|
||||
const { announcements, mostRecent } = useAnnouncements();
|
||||
const { t } = useI18n();
|
||||
|
||||
const releaseSeen = useProfileStorage("releaseSeen", config.version);
|
||||
|
||||
function summary(release: { features: number; bugFixes: number; breaking: number }) {
|
||||
if (release.features > 0 && release.bugFixes > 0 && release.breaking > 0) {
|
||||
return t("releases.three_parts", {
|
||||
first: t("releases.breaking", { count: release.breaking }),
|
||||
second: t("releases.features", { count: release.features }),
|
||||
third: t("releases.bugFixes", { count: release.bugFixes }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.features > 0 && release.bugFixes > 0) {
|
||||
return t("releases.two_parts", {
|
||||
first: t("releases.features", { count: release.features }),
|
||||
second: t("releases.bugFixes", { count: release.bugFixes }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.features > 0 && release.breaking > 0) {
|
||||
return t("releases.two_parts", {
|
||||
first: t("releases.features", { count: release.features }),
|
||||
second: t("releases.breaking", { count: release.breaking }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.bugFixes > 0 && release.breaking > 0) {
|
||||
return t("releases.two_parts", {
|
||||
first: t("releases.bugFixes", { count: release.bugFixes }),
|
||||
second: t("releases.breaking", { count: release.breaking }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.features > 0) {
|
||||
return t("releases.features", { count: release.features });
|
||||
}
|
||||
|
||||
if (release.bugFixes > 0) {
|
||||
return t("releases.bugFixes", { count: release.bugFixes });
|
||||
}
|
||||
|
||||
if (release.breaking > 0) {
|
||||
return t("releases.breaking", { count: release.breaking });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="font-light capitalize"> RUNNING </span>
|
||||
<span class="font-light capitalize"> STATE </span>
|
||||
<span class="font-semibold uppercase"> {{ container.state }} </span>
|
||||
</div>
|
||||
<div v-if="container.startedAt.getFullYear() > 0">
|
||||
<span class="font-light capitalize"> STARTED </span>
|
||||
<span class="font-semibold">
|
||||
<DistanceTime :date="container.created" strict :suffix="false" />
|
||||
<DistanceTime :date="container.startedAt" strict />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
|
||||
<span class="font-light capitalize"> FINISHED </span>
|
||||
<span class="font-semibold">
|
||||
<DistanceTime :date="container.finishedAt" strict />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="container.state == 'running'">
|
||||
<span class="font-light capitalize"> Load </span>
|
||||
<span class="font-semibold"> {{ container.stat.cpu.toFixed(2) }}% </span>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="container.state == 'running'">
|
||||
<span class="font-light capitalize"> MEM </span>
|
||||
<span class="font-semibold"> {{ formatBytes(container.stat.memoryUsage) }} </span>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab !rounded"
|
||||
class="tab rounded-sm!"
|
||||
aria-label="Show All"
|
||||
v-model="selectedHost"
|
||||
:value="null"
|
||||
@@ -16,7 +16,7 @@
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab !rounded"
|
||||
class="tab rounded-sm!"
|
||||
:aria-label="host.name"
|
||||
v-for="host in hosts"
|
||||
:value="host.id"
|
||||
@@ -24,8 +24,8 @@
|
||||
v-model="selectedHost"
|
||||
/>
|
||||
</div>
|
||||
<!-- @vue-ignore -->
|
||||
<dropdown-menu
|
||||
|
||||
<DropdownMenu
|
||||
class="btn-sm"
|
||||
v-model="selectedHost"
|
||||
:options="[
|
||||
@@ -37,8 +37,8 @@
|
||||
</div>
|
||||
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
|
||||
{{ $t("label.per-page") }}
|
||||
<!-- @vue-ignore -->
|
||||
<dropdown-menu
|
||||
|
||||
<DropdownMenu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-lg bg-base">
|
||||
<table class="table-lg bg-base-200 table">
|
||||
<thead>
|
||||
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
|
||||
<th
|
||||
@@ -196,7 +196,9 @@ function isVisible(field: keys) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
|
||||
[data-icon] {
|
||||
display: none;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
@@ -206,7 +208,7 @@ function isVisible(field: keys) {
|
||||
}
|
||||
|
||||
th {
|
||||
@apply border-b-2 border-base-lighter;
|
||||
@apply border-base-100 border-b-2;
|
||||
&.selected-sort {
|
||||
font-weight: bold;
|
||||
@apply border-primary;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm gap-0.5 px-2">
|
||||
<carbon:circle-solid class="w-2.5 text-red" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="w-2.5 text-blue" v-if="streamConfig.stdout" />
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-10 gap-0.5 px-2">
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content z-50 w-52 rounded-box bg-base p-1 shadow">
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
@@ -28,46 +28,78 @@
|
||||
</li>
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = true;
|
||||
streamConfig.stderr = true;
|
||||
"
|
||||
>
|
||||
<div class="flex size-4 gap-0.5">
|
||||
<template v-if="streamConfig.stderr && streamConfig.stdout">
|
||||
<carbon:circle-solid class="w-2 text-red" />
|
||||
<carbon:circle-solid class="w-2 text-blue" />
|
||||
</template>
|
||||
</div>
|
||||
{{ $t("toolbar.show-all") }}
|
||||
</a>
|
||||
<details>
|
||||
<summary>
|
||||
<div class="flex w-4">
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
</div>
|
||||
Streams
|
||||
</summary>
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = true;
|
||||
streamConfig.stderr = true;
|
||||
"
|
||||
>
|
||||
<mdi:check class="w-4" v-if="streamConfig.stdout == true && streamConfig.stderr == true" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("toolbar.show-all") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = true;
|
||||
streamConfig.stderr = false;
|
||||
"
|
||||
>
|
||||
<mdi:check class="w-4" v-if="streamConfig.stdout == true && streamConfig.stderr == false" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("toolbar.show", { std: "STDOUT" }) }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = false;
|
||||
streamConfig.stderr = true;
|
||||
"
|
||||
>
|
||||
<mdi:check class="w-4" v-if="streamConfig.stdout == false && streamConfig.stderr == true" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("toolbar.show", { std: "STDERR" }) }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = true;
|
||||
streamConfig.stderr = false;
|
||||
"
|
||||
>
|
||||
<div class="flex size-4 flex-col gap-1">
|
||||
<carbon:circle-solid class="w-2 text-blue" v-if="!streamConfig.stderr && streamConfig.stdout" />
|
||||
</div>
|
||||
{{ $t("toolbar.show", { std: "STDOUT" }) }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = false;
|
||||
streamConfig.stderr = true;
|
||||
"
|
||||
>
|
||||
<div class="flex size-4 flex-col gap-1">
|
||||
<carbon:circle-solid class="w-2 text-red" v-if="streamConfig.stderr && !streamConfig.stdout" />
|
||||
</div>
|
||||
{{ $t("toolbar.show", { std: "STDERR" }) }}
|
||||
</a>
|
||||
<details class="group/details">
|
||||
<summary>
|
||||
<mdi:gauge />
|
||||
Levels
|
||||
<Toggle
|
||||
class="toggle-xs invisible group-open/details:visible"
|
||||
v-model="toggleAllLevels"
|
||||
title="Toggle all levels"
|
||||
/>
|
||||
</summary>
|
||||
<ul class="menu">
|
||||
<li v-for="level in allLevels">
|
||||
<a class="capitalize" @click="levels.has(level) ? levels.delete(level) : levels.add(level)">
|
||||
<mdi:check class="w-4" v-if="levels.has(level)" />
|
||||
<div v-else class="w-4"></div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="badge" :data-level="level">{{ level }}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<!-- Container Actions (Enabled via config) -->
|
||||
@@ -102,17 +134,33 @@
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<a @click.prevent="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
|
||||
<ri:terminal-window-fill /> Attach
|
||||
<KeyShortcut char="a" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
|
||||
<material-symbols:terminal /> Shell
|
||||
<KeyShortcut char="e" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { allLevels } from "@/composable/logContext";
|
||||
import LogAnalytics from "../LogViewer/LogAnalytics.vue";
|
||||
import Terminal from "@/components/Terminal.vue";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { enableActions } = config;
|
||||
const { streamConfig, hasComplexLogs } = useLoggingContext();
|
||||
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
|
||||
const showDrawer = useDrawer();
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
@@ -128,6 +176,20 @@ onKeyStroke("f", (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke("a", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(Terminal, { container, action: "attach" }, "lg");
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke("e", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(Terminal, { container, action: "exec" }, "lg");
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
@@ -136,19 +198,39 @@ const downloadParams = computed(() =>
|
||||
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/hosts/${container.host}/containers/${container.id}/logs/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
`/api/containers/${container.host}~${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
|
||||
|
||||
const toggleAllLevels = computed({
|
||||
get: () => levels.value.size === allLevels.length,
|
||||
set: (value) => {
|
||||
if (value) {
|
||||
allLevels.forEach((level) => levels.value.add(level));
|
||||
} else {
|
||||
levels.value.clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
|
||||
li.line {
|
||||
@apply h-px bg-base-content/20;
|
||||
@apply bg-base-content/20 h-px;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
.menu li ul {
|
||||
margin-inline-start: 0;
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,8 @@ defineProps<{
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
[health="unhealthy"] {
|
||||
@apply text-red;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
|
||||
<ContainerTitle :container="container" />
|
||||
<MultiContainerStat class="ml-auto" :containers="[container]" />
|
||||
<MultiContainerStat
|
||||
class="ml-auto lg:hidden lg:@3xl:flex"
|
||||
:containers="[container]"
|
||||
v-if="container.state === 'running'"
|
||||
/>
|
||||
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" class="mobile-hidden" :container="container" />
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" class="max-md:hidden" :container="container" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
@@ -17,7 +21,6 @@
|
||||
:stream-source="useContainerStream"
|
||||
:entity="container"
|
||||
:visible-keys="visibleKeys"
|
||||
:show-container-name="false"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
@@ -46,5 +49,8 @@ const container = store.currentContainer(toRef(() => id));
|
||||
const visibleKeys = persistentVisibleKeysForContainer(container);
|
||||
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
|
||||
provideLoggingContext(toRef(() => [container.value]));
|
||||
provideLoggingContext(
|
||||
toRef(() => [container.value]),
|
||||
{ showContainerName: false, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<label class="swap swap-rotate size-4">
|
||||
<input type="checkbox" v-model="pinned" />
|
||||
<carbon:star-filled class="swap-on text-secondary" />
|
||||
<carbon:star class="swap-off" />
|
||||
</label>
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div v-if="config.hosts.length > 1" class="mobile-hidden font-thin">
|
||||
{{ container.hostLabel }}<span class="mx-2">/</span>
|
||||
</div>
|
||||
<div class="font-semibold">{{ container.name }}</div>
|
||||
<div
|
||||
class="mobile-hidden max-w-[1.5em] truncate transition-[max-width] hover:max-w-[400px]"
|
||||
v-if="container.isSwarm"
|
||||
>
|
||||
.{{ container.swarmId }}
|
||||
<div class="inline-flex items-center text-sm">
|
||||
<div class="breadcrumbs p-0">
|
||||
<ul>
|
||||
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
|
||||
{{ container.hostLabel }}
|
||||
</li>
|
||||
<li>
|
||||
<div v-if="otherContainers.length === 0" class="font-mono">{{ container.name }}</div>
|
||||
<div class="wrapper" ref="wrapper" v-else>
|
||||
<button popovertarget="popover-container-list" class="btn btn-xs md:btn-sm anchor font-mono">
|
||||
{{ container.name }} <carbon:caret-down />
|
||||
</button>
|
||||
|
||||
<ul popover id="popover-container-list" class="dropdown menu rounded-box bg-base-100 tethered shadow-sm">
|
||||
<li v-for="other in otherContainers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
<div class="font-mono" v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div class="font-mono" v-else>{{ other.name }}</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<DistanceTime :date="other.created" strict class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<Tag class="mobile-hidden hidden font-mono @3xl:block" size="small">
|
||||
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -38,4 +58,42 @@ const pinned = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
const store = useContainerStore();
|
||||
const { containers: allContainers } = storeToRefs(store);
|
||||
|
||||
const otherContainers = computed(() =>
|
||||
allContainers.value
|
||||
.filter((c) => c.name === container.name && c.id !== container.id)
|
||||
.sort((a, b) => +b.created - +a.created),
|
||||
);
|
||||
const wrapper = useTemplateRef("wrapper");
|
||||
|
||||
onMounted(async () => {
|
||||
if (!("anchorName" in document.documentElement.style)) {
|
||||
// @ts-ignore
|
||||
const module = await import("@oddbird/css-anchor-positioning/fn");
|
||||
// @ts-ignore
|
||||
await module.default([wrapper.value]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* https://github.com/oddbird/css-anchor-positioning/issues/282 */
|
||||
.wrapper {
|
||||
anchor-scope: --anchor;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
anchor-name: --anchor;
|
||||
}
|
||||
|
||||
.tethered {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position-anchor: --anchor;
|
||||
position: absolute;
|
||||
top: anchor(bottom);
|
||||
left: anchor(left);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,9 +31,45 @@ function createFuzzySearchModal() {
|
||||
initialState: {
|
||||
container: {
|
||||
containers: [
|
||||
new Container("123", new Date(), "image", "test", "command", "host", {}, "running", []),
|
||||
new Container("345", new Date(), "image", "foo bar", "command", "host", {}, "running", []),
|
||||
new Container("567", new Date(), "image", "baz", "command", "host", {}, "running", []),
|
||||
new Container(
|
||||
"123",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"test",
|
||||
"command",
|
||||
"host",
|
||||
{},
|
||||
"running",
|
||||
[],
|
||||
),
|
||||
new Container(
|
||||
"345",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"foo bar",
|
||||
"command",
|
||||
"host",
|
||||
{},
|
||||
"running",
|
||||
[],
|
||||
),
|
||||
new Container(
|
||||
"567",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"baz",
|
||||
"command",
|
||||
"host",
|
||||
{},
|
||||
"running",
|
||||
[],
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-open w-full">
|
||||
<div class="input input-primary flex h-auto items-center">
|
||||
<div class="input input-xl input-primary flex w-full items-center">
|
||||
<mdi:magnify class="flex size-8" />
|
||||
<input
|
||||
tabindex="0"
|
||||
class="input input-lg input-ghost flex-1 px-1"
|
||||
class="input-ghost flex-1 px-1"
|
||||
ref="input"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||
@@ -14,22 +14,23 @@
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
/>
|
||||
<form method="dialog">
|
||||
<button class="swap swap-rotate hover:swap-active">
|
||||
<button class="swap hover:swap-active">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<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"
|
||||
class="dropdown-content bg-base-100 relative! mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent px-2"
|
||||
tabindex="0"
|
||||
v-if="results.length"
|
||||
>
|
||||
<ul tabindex="0" class="menu">
|
||||
<ul class="menu w-auto">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="grid auto-cols-max grid-cols-[min-content,auto] gap-2 py-4"
|
||||
class="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
|
||||
@click.prevent="selected(result.item)"
|
||||
:class="index === selectedIndex ? 'focus' : ''"
|
||||
:class="index === selectedIndex ? 'menu-focus' : ''"
|
||||
>
|
||||
<div :class="{ 'text-primary': result.item.state === 'running' }">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
@@ -85,6 +86,15 @@ const { visibleContainers } = storeToRefs(containerStore);
|
||||
const swarmStore = useSwarmStore();
|
||||
const { stacks, services } = storeToRefs(swarmStore);
|
||||
|
||||
onMounted(async () => {
|
||||
const dialog = input.value?.closest("dialog");
|
||||
if (dialog) {
|
||||
const animations = dialog.getAnimations();
|
||||
await Promise.all(animations.map((animation) => animation.finished));
|
||||
input.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
created: Date;
|
||||
@@ -167,8 +177,6 @@ watch(selectedIndex, () => {
|
||||
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
|
||||
});
|
||||
|
||||
useFocus(input, { initialValue: true });
|
||||
|
||||
function selected(item: Item) {
|
||||
if (item.type === "container") {
|
||||
router.push({ name: "/container/[id]", params: { id: item.id } });
|
||||
@@ -179,6 +187,7 @@ function selected(item: Item) {
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function addColumn(container: { id: string }) {
|
||||
pinnedStore.pinContainer(container);
|
||||
close();
|
||||
@@ -204,7 +213,8 @@ function matchedName({ item, matches = [] }: FuseResult<Item>) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
:deep(mark) {
|
||||
@apply bg-transparent text-inherit underline underline-offset-2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<ul class="menu w-full p-0 text-[0.95rem]">
|
||||
<li v-if="customGroups.length > 0">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:bounding-box-fill />
|
||||
{{ $t("label.group-menu") }}
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="group in customGroups" :key="group.name">
|
||||
<router-link :to="{ name: '/group/[name]', params: { name: group.name } }" active-class="menu-active">
|
||||
<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 { customGroups } = storeToRefs(store);
|
||||
</script>
|
||||
<style scoped></style>
|
||||
@@ -2,13 +2,13 @@
|
||||
<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="@container flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ group.containers.length }} containers</div>
|
||||
<div class="font-semibold">{{ $t("label.container", group.containers.length) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="group.containers" />
|
||||
<MultiContainerActionToolbar class="mobile-hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -17,7 +17,6 @@
|
||||
:stream-source="useGroupedStream"
|
||||
:entity="group"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
@@ -43,5 +42,8 @@ const { customGroups } = storeToRefs(swarmStore);
|
||||
|
||||
const group = computed(() => customGroups.value.find((g) => g.name === name) ?? new GroupedContainers("", []));
|
||||
|
||||
provideLoggingContext(toRef(() => group.value.containers));
|
||||
provideLoggingContext(
|
||||
toRef(() => group.value.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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">
|
||||
<li v-for="host in hosts" class="card bg-base-100">
|
||||
<div class="card-body grid auto-cols-auto grid-flow-col justify-between gap-4">
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="flex items-center gap-1 truncate text-xl font-semibold">
|
||||
@@ -23,15 +23,16 @@
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-row gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1"><ph:cpu /> {{ host.nCPU }} <span class="mobile-hidden">CPUs</span></li>
|
||||
<li class="flex items-center gap-1"><ph:cpu /> {{ host.nCPU }} <span class="max-md:hidden">CPUs</span></li>
|
||||
<li class="flex items-center gap-1">
|
||||
<ph:memory /> {{ formatBytes(host.memTotal) }}
|
||||
<span class="mobile-hidden">total</span>
|
||||
<span class="max-md:hidden">total</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex flex-row flex-wrap gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1">
|
||||
<octicon:container-24 class="inline-block" /> {{ $t("label.container", hostContainers[host.id]?.length) }}
|
||||
<octicon:container-24 class="inline-block" />
|
||||
{{ $t("label.container", hostContainers[host.id]?.length ?? 0) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
</ul>
|
||||
@@ -39,15 +40,15 @@
|
||||
|
||||
<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)}; `"
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalCPU / (host.nCPU * 100)) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ weightedStats[host.id].weighted.totalCPU.toFixed(0) }}%
|
||||
</div>
|
||||
<div
|
||||
class="radial-progress text-sm text-primary [--size:4rem] [--thickness:0.25em] md:text-[1rem] md:[--size:5rem]"
|
||||
:style="`--value: ${(weightedStats[host.id].weighted.totalMem / host.memTotal) * 100};`"
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalMem / host.memTotal) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ formatBytes(weightedStats[host.id].weighted.totalMem, 1) }}
|
||||
@@ -107,12 +108,13 @@ useIntervalFn(
|
||||
stat.totalCPU += container.stat.cpu;
|
||||
stat.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
weightedStats[host].mostRecent = stat;
|
||||
if (weightedStats[host]) {
|
||||
// TODO fix this init
|
||||
weightedStats[host].mostRecent = stat;
|
||||
}
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
<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 class="flex items-center">
|
||||
<div class="breadcrumbs flex-1">
|
||||
<ul>
|
||||
<li>
|
||||
<a @click.prevent="setHost(null)" class="link-primary">{{ $t("label.hosts") }}</a>
|
||||
</li>
|
||||
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
|
||||
<router-link
|
||||
:to="{
|
||||
name: '/host/[id]',
|
||||
params: { id: hosts[sessionHost].id },
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-hosts')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ hosts[sessionHost].name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
|
||||
<ph:dots-three-vertical-bold />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a class="text-sm capitalize" @click="toggleShowAllContainers()">
|
||||
<mdi:check class="w-4" v-if="showAllContainers" />
|
||||
<div v-else class="w-4"></div>
|
||||
|
||||
Show all containers
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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 }">
|
||||
<a @click.prevent="setHost(host.id)" :class="{ 'text-base-content/50 pointer-events-none': !host.available }">
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
@@ -23,10 +53,10 @@
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
<ul class="containers menu p-0 [&_li.menu-title]:px-0">
|
||||
<ul class="containers menu w-full p-0 [&_li.menu-title]:px-0">
|
||||
<li v-for="{ label, containers, icon } in menuItems" :key="label">
|
||||
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
|
||||
<summary class="font-light text-base-content/80">
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<component :is="icon" />
|
||||
{{ label.startsWith("label.") ? $t(label) : label }}
|
||||
|
||||
@@ -47,17 +77,21 @@
|
||||
<Popup>
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: item.id } }"
|
||||
active-class="active-primary"
|
||||
active-class="menu-active"
|
||||
@click.alt.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
:title="item.name"
|
||||
class="group auto-cols-[auto_max-content_max-content]"
|
||||
class="group auto-cols-[content_max_auto_max-content_max-content]"
|
||||
>
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
:data-state="item.state"
|
||||
></div>
|
||||
<div class="truncate">
|
||||
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">.{{ item.swarmId }}</span>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<ContainerHealth :health="item.health" />
|
||||
<span
|
||||
class="hidden hover:text-secondary group-hover:inline-block"
|
||||
class="hover:text-secondary hidden group-hover:inline-block"
|
||||
@click.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
v-show="!pinnedStore.isPinned(item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
@@ -81,6 +115,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
import { showAllContainers } from "@/stores/settings";
|
||||
|
||||
// @ts-ignore
|
||||
import Pin from "~icons/ph/map-pin-simple";
|
||||
@@ -175,14 +210,16 @@ watchEffect(() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const toggleShowAllContainers = () => (showAllContainers.value = !showAllContainers.value);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
.menu {
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
|
||||
li.exited {
|
||||
@apply opacity-50;
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
li.deleted {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="host">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<ph:computer-tower />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ host.name }}</div>
|
||||
</div>
|
||||
<Tag class="font-mono max-md:hidden" size="small">
|
||||
{{ $t("label.container", containers.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useHostStream"
|
||||
:entity="host"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
const { id, scrollable = false } = defineProps<{
|
||||
id: string;
|
||||
scrollable?: boolean;
|
||||
}>();
|
||||
const store = useContainerStore();
|
||||
const { containersByHost } = storeToRefs(store);
|
||||
const { hosts } = useHosts();
|
||||
const host = computed(() => hosts.value[id]);
|
||||
const containers = computed(() => containersByHost.value?.[id]?.filter((c) => c.state === "running") ?? []);
|
||||
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
provideLoggingContext(containers, { showContainerName: true, showHostname: false });
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="root" class="flex min-h-[1px] justify-center">
|
||||
<span class="loading loading-bars loading-md mt-4 text-primary" v-show="isLoading"></span>
|
||||
<span class="loading loading-bars loading-md text-primary mt-4" v-show="isLoading"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<slot name="more-items"></slot>
|
||||
<Dropdown class="dropdown-end" @closed="latestTag = latest?.tag ?? config.version">
|
||||
<template #trigger>
|
||||
<mdi:announcement class="size-6 -rotate-12" />
|
||||
<span
|
||||
class="absolute right-px top-0 size-2 rounded-full bg-red"
|
||||
v-if="hasUpdate && latestTag != latest?.tag"
|
||||
></span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-72">
|
||||
<Releases />
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Announcements />
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/settings' }"
|
||||
@@ -28,7 +15,7 @@
|
||||
<dropdown class="dropdown-end" v-if="config.user">
|
||||
<template #trigger>
|
||||
<img
|
||||
class="size-6 max-w-none rounded-full p-px ring-1 ring-base-content/60"
|
||||
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
|
||||
:src="withBase('/api/profile/avatar')"
|
||||
/>
|
||||
</template>
|
||||
@@ -43,7 +30,7 @@
|
||||
</div>
|
||||
<ul class="menu mt-4 p-0">
|
||||
<li v-if="config.authProvider === 'simple'">
|
||||
<button @click.prevent="logout()" class="p-2 text-primary">{{ $t("button.logout") }}</button>
|
||||
<button @click.prevent="logout()" class="text-primary p-2">{{ $t("button.logout") }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -58,7 +45,4 @@ async function logout() {
|
||||
|
||||
location.reload();
|
||||
}
|
||||
|
||||
const { hasUpdate, latest } = useReleases();
|
||||
const latestTag = useProfileStorage("releaseSeen", config.version);
|
||||
</script>
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
<template>
|
||||
<div class="group/item clickable relative flex w-full gap-x-2" @click="showDrawer(LogDetails, { entry: logEntry })">
|
||||
<div v-if="showContainerName">
|
||||
<ContainerName :id="logEntry.containerID" />
|
||||
</div>
|
||||
<div v-if="showStd">
|
||||
<LogStd :std="logEntry.std" />
|
||||
</div>
|
||||
<div v-if="showTimestamp">
|
||||
<LogDate :date="logEntry.date" class="select-none" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<LogLevel :level="logEntry.level" />
|
||||
</div>
|
||||
<div>
|
||||
<ul class="fields space-x-4">
|
||||
<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="JSON.stringify(value)"> </span>
|
||||
</template>
|
||||
<span class="font-bold" v-html="value" v-else></span>
|
||||
</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<LogItem :logEntry @click="showDrawer(LogDetails, { entry: logEntry })" class="clickable">
|
||||
<ul class="space-x-4">
|
||||
<li v-for="(value, name) in validValues" :key="name" class="inline-flex">
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null"><null></span>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<span class="font-bold" v-html="JSON.stringify(value)"> </span>
|
||||
</template>
|
||||
<span class="font-bold" v-html="stripAnsi(value.toString())" v-else></span>
|
||||
</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { type ComplexLogEntry } from "@/models/LogEntry";
|
||||
import LogDetails from "./LogDetails.vue";
|
||||
|
||||
const { logEntry, showContainerName = false } = defineProps<{
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: ComplexLogEntry;
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
@@ -42,14 +29,9 @@ const validValues = computed(() => {
|
||||
const showDrawer = useDrawer();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.text-light {
|
||||
@apply text-base-content/70;
|
||||
}
|
||||
|
||||
.fields {
|
||||
li {
|
||||
@apply inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,84 +1,19 @@
|
||||
<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>
|
||||
<LogItem :logEntry>
|
||||
<div class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></div>
|
||||
</LogItem>
|
||||
</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>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
[data-event="container-stopped"] {
|
||||
@apply text-red;
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<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>
|
||||
@@ -11,6 +11,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import { default as Component } from "./EventSource.vue";
|
||||
import LogViewer from "@/components/LogViewer/LogViewer.vue";
|
||||
import { Container } from "@/models/Container";
|
||||
import { Level } from "@/models/LogEntry";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
@@ -67,7 +68,7 @@ describe("<ContainerEventSource />", () => {
|
||||
],
|
||||
});
|
||||
|
||||
return mount(Component<Container>, {
|
||||
return mount(Component, {
|
||||
global: {
|
||||
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
|
||||
components: {
|
||||
@@ -82,6 +83,7 @@ describe("<ContainerEventSource />", () => {
|
||||
containers: computed(() => [{ id: "abc", image: "test:v123", host: "localhost" }]),
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
hasComplexLogs: ref(false),
|
||||
levels: new Set<Level>(["info"]),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -92,16 +94,28 @@ describe("<ContainerEventSource />", () => {
|
||||
},
|
||||
props: {
|
||||
streamSource: useContainerStream,
|
||||
entity: new Container("abc", new Date(), "image", "name", "command", "localhost", {}, "created", []),
|
||||
entity: new Container(
|
||||
"abc",
|
||||
new Date(), // created
|
||||
new Date(), // started
|
||||
new Date(), // finished
|
||||
"image",
|
||||
"name",
|
||||
"command",
|
||||
"localhost",
|
||||
{},
|
||||
"created",
|
||||
[],
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sourceUrl = "/api/hosts/localhost/containers/abc/logs/stream?stdout=1&stderr=1";
|
||||
const sourceUrl = "/api/hosts/localhost/containers/abc/logs/stream?stdout=1&stderr=1&levels=info";
|
||||
|
||||
test("renders loading correctly", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul.animate-pulse").exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("should connect to EventSource", async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
|
||||
<slot :messages="messages"></slot>
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
|
||||
<div class="flex flex-row gap-2" v-for="size in sizes">
|
||||
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
|
||||
<div class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ul>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">Container has no logs yet</div>
|
||||
<slot :messages="messages" v-else></slot>
|
||||
<IndeterminateBar :color />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="T">
|
||||
@@ -11,10 +20,18 @@ const { entity, streamSource } = $defineProps<{
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
const { messages, loadOlderLogs, isLoadingMore } = streamSource($$(entity));
|
||||
const { messages, loadOlderLogs, isLoadingMore, opened, loading, error, eventSourceURL } = streamSource($$(entity));
|
||||
const { loadingMore } = useLoggingContext();
|
||||
const color = computed(() => {
|
||||
if (error.value) return "error";
|
||||
if (loading.value) return "secondary";
|
||||
if (opened.value) return "primary";
|
||||
return "error";
|
||||
});
|
||||
|
||||
const enabled = ref(true);
|
||||
const noLogs = computed(() => messages.value.length === 0);
|
||||
const waitingForMoreLog = refAutoReset(false, 3000);
|
||||
watchImmediate(loading, () => (waitingForMoreLog.value = true));
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
@@ -23,10 +40,16 @@ defineExpose({
|
||||
const fetchMore = async () => {
|
||||
if (!isLoadingMore.value) {
|
||||
loadingMore.value = true;
|
||||
enabled.value = false;
|
||||
await loadOlderLogs();
|
||||
loadingMore.value = false;
|
||||
enabled.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const shuffle = (items: any[]) => {
|
||||
return items.sort(() => Math.random() - 0.5);
|
||||
};
|
||||
|
||||
const sizes = computedWithControl(eventSourceURL, () =>
|
||||
shuffle(["w-3/5", "w-2/3", "w-9/12", "w-1/2", "w-1/3", "w-3/4"]),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<aside>
|
||||
<header class="flex items-center gap-4">
|
||||
<h1 class="mobile-hidden text-2xl">{{ container.name }}</h1>
|
||||
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
|
||||
<h2 class="text-sm"><DistanceTime :date="container.created" /></h2>
|
||||
</header>
|
||||
|
||||
@@ -13,51 +13,25 @@
|
||||
class="textarea textarea-primary w-full font-mono text-lg"
|
||||
:class="{ 'textarea-error': error }"
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<div class="label max-h-48 overflow-y-auto pr-2">
|
||||
<span class="label-text-alt text-error" v-if="error">{{ error }}</span>
|
||||
<span class="label-text-alt" v-else>
|
||||
Total {{ results.numRows }} records
|
||||
<template v-if="results.numRows > pageLimit">. Showing first {{ page.numRows }}.</template></span
|
||||
>
|
||||
<template v-if="results.numRows > pageLimit"> . Showing first {{ page.numRows }}. </template>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<table class="table table-zebra table-pin-rows table-md" v-if="!evaluating && isReady">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in page" :key="row">
|
||||
<td v-for="column in columns" :key="column">{{ row[column] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-md animate-pulse" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="_ in 3">
|
||||
<div class="h-4 w-20 animate-pulse bg-base-content/50 opacity-50"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="_ in 9">
|
||||
<td v-for="_ in 3">
|
||||
<div class="h-4 w-20 bg-base-content/50 opacity-20"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SQLTable :table="page" :loading="evaluating || !isReady" />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
import { type Table } from "@apache-arrow/esnext-esm";
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
const query = ref("SELECT * FROM logs");
|
||||
const query = ref("SELECT * FROM logs LIMIT 100");
|
||||
const error = ref<string | null>(null);
|
||||
const debouncedQuery = debouncedRef(query, 500);
|
||||
const evaluating = ref(false);
|
||||
@@ -115,13 +89,9 @@ const results = computedAsync(
|
||||
},
|
||||
);
|
||||
|
||||
whenever(evaluating, () => {
|
||||
error.value = null;
|
||||
});
|
||||
|
||||
const columns = computed(() =>
|
||||
results.value.numRows > 0 ? Object.keys(results.value.get(0) as Record<string, any>) : [],
|
||||
);
|
||||
const page = computed(() => (results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value));
|
||||
whenever(evaluating, () => (error.value = null));
|
||||
const page = computed(() =>
|
||||
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
|
||||
) as unknown as ComputedRef<Table<Record<string, any>>>;
|
||||
</script>
|
||||
<style lang="postcss" scoped></style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Tag size="small">
|
||||
<DateTime :date="date" class="whitespace-nowrap text-blue" />
|
||||
<Tag size="small" class="items-start!">
|
||||
<DateTime :date="date" class="text-blue whitespace-nowrap" />
|
||||
</Tag>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header class="flex items-center gap-4">
|
||||
<Tag :data-level="entry.level" class="uppercase text-white" v-if="entry.level">{{ entry.level }}</Tag>
|
||||
<h1 class="mobile-hidden text-lg">
|
||||
<Tag :data-level="entry.level" class="text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
|
||||
<h1 class="text-lg max-md:hidden">
|
||||
<DateTime :date="entry.date" />
|
||||
</h1>
|
||||
<h2 class="text-sm"><DistanceTime :date="entry.date" /> on {{ entry.std }}</h2>
|
||||
@@ -26,37 +26,39 @@
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex gap-2 font-thin">
|
||||
<div class="flex gap-2">
|
||||
Raw JSON
|
||||
|
||||
<UseClipboard v-slot="{ copy, copied }" :source="JSON.stringify(entry.unfilteredMessage)">
|
||||
<button class="swap swap-flip outline-none" @click="copy()" :class="{ 'hover:swap-active': copied }">
|
||||
<button class="swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
|
||||
<mdi:check class="swap-on" />
|
||||
<mdi:content-copy class="swap-off" />
|
||||
</button>
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-scroll rounded border border-base-lighter bg-base-darker p-2">
|
||||
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<pre v-html="syntaxHighlight(entry.unfilteredMessage)"></pre>
|
||||
</div>
|
||||
</section>
|
||||
<table class="table table-pin-rows table-fixed" v-if="entry instanceof ComplexLogEntry">
|
||||
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
|
||||
<caption class="caption-bottom">
|
||||
Fields are sortable by dragging and dropping.
|
||||
</caption>
|
||||
<thead class="text-lg">
|
||||
<tr>
|
||||
<th class="w-60">Field</th>
|
||||
<th class="mobile-hidden">Value</th>
|
||||
<th class="w-20"><input type="checkbox" class="toggle toggle-primary" @change="toggleAll($event)" /></th>
|
||||
<th class="max-md:hidden">Value</th>
|
||||
<th class="w-20">
|
||||
<input type="checkbox" class="toggle toggle-primary" v-model="toggleAllFields" title="Toggle all" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody ref="list">
|
||||
<tr v-for="{ key, value, enabled } in fields" :key="key.join('.')" class="hover">
|
||||
<td class="cursor-move break-all font-mono">
|
||||
<td class="cursor-move font-mono break-all">
|
||||
{{ key.join(".") }}
|
||||
</td>
|
||||
<td class="mobile-hidden truncate">
|
||||
<td class="truncate max-md:hidden">
|
||||
<code v-html="JSON.stringify(value)"></code>
|
||||
</td>
|
||||
<td>
|
||||
@@ -70,7 +72,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ComplexLogEntry } from "@/models/LogEntry";
|
||||
import { useSortable } from "@vueuse/integrations/useSortable";
|
||||
import { UseClipboard } from "@vueuse/components";
|
||||
|
||||
const { entry } = defineProps<{ entry: ComplexLogEntry }>();
|
||||
@@ -80,26 +81,18 @@ const container = currentContainer(toRef(() => entry.containerID));
|
||||
const visibleKeys = persistentVisibleKeysForContainer(container);
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const { useSortable } = await import("@vueuse/integrations/useSortable");
|
||||
|
||||
function toggleField(key: string[]) {
|
||||
if (visibleKeys.value.size === 0) {
|
||||
visibleKeys.value = new Map<string[], boolean>(fields.value.map(({ key }) => [key, true]));
|
||||
}
|
||||
|
||||
const enabled = visibleKeys.value.get(key);
|
||||
const enabled = visibleKeys.value.get(key) ?? true;
|
||||
|
||||
visibleKeys.value.set(key, !enabled);
|
||||
}
|
||||
|
||||
function toggleAll(e: Event) {
|
||||
if (visibleKeys.value.size === 0) {
|
||||
visibleKeys.value = new Map<string[], boolean>(fields.value.map(({ key }) => [key, true]));
|
||||
}
|
||||
|
||||
const enabled = e.target instanceof HTMLInputElement && e.target.checked;
|
||||
for (const key of visibleKeys.value.keys()) {
|
||||
visibleKeys.value.set(key, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = computed({
|
||||
get() {
|
||||
const fieldsWithValue: { key: string[]; value: any; enabled: boolean }[] = [];
|
||||
@@ -116,7 +109,7 @@ const fields = computed({
|
||||
|
||||
for (const [key, value] of allFields) {
|
||||
if ([...visibleKeys.value.keys()].findIndex((k) => arrayEquals(k, key)) === -1) {
|
||||
fieldsWithValue.push({ key, value, enabled: false });
|
||||
fieldsWithValue.push({ key, value, enabled: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,14 +125,30 @@ const fields = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAllFields = computed({
|
||||
get: () => fields.value.every(({ enabled }) => enabled),
|
||||
set(value) {
|
||||
if (visibleKeys.value.size === 0) {
|
||||
visibleKeys.value = new Map<string[], boolean>(fields.value.map(({ key }) => [key, true]));
|
||||
}
|
||||
for (const key of visibleKeys.value.keys()) {
|
||||
visibleKeys.value.set(key, value);
|
||||
}
|
||||
|
||||
for (const field of fields.value) {
|
||||
visibleKeys.value.set(field.key, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function syntaxHighlight(json: any) {
|
||||
json = JSON.stringify(json, null, 2);
|
||||
return json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
|
||||
function (match: string) {
|
||||
var cls = "json-number";
|
||||
if (/^"/.test(match)) {
|
||||
if (/:$/.test(match)) {
|
||||
if (match.startsWith('"')) {
|
||||
if (match.endsWith(":")) {
|
||||
cls = "json-key";
|
||||
} else {
|
||||
cls = "json-string";
|
||||
@@ -149,14 +158,15 @@ function syntaxHighlight(json: any) {
|
||||
} else if (/null/.test(match)) {
|
||||
cls = "json-null";
|
||||
}
|
||||
return '<span class="' + cls + '">' + match + "</span>";
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
useSortable(list, fields);
|
||||
</script>
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.font-mono {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
@@ -170,19 +180,19 @@ useSortable(list, fields);
|
||||
}
|
||||
|
||||
pre {
|
||||
:deep(.json-key) {
|
||||
& :deep(.json-key) {
|
||||
@apply text-blue;
|
||||
}
|
||||
:deep(.json-string) {
|
||||
& :deep(.json-string) {
|
||||
@apply text-green;
|
||||
}
|
||||
:deep(.json-number) {
|
||||
& :deep(.json-number) {
|
||||
@apply text-orange;
|
||||
}
|
||||
:deep(.json-boolean) {
|
||||
& :deep(.json-boolean) {
|
||||
@apply text-purple;
|
||||
}
|
||||
:deep(.json-null) {
|
||||
& :deep(.json-null) {
|
||||
@apply text-red;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<LogStd :std="logEntry.std" class="shrink-0 select-none" v-if="showStd" />
|
||||
<RandomColorTag class="shrink-0 select-none" :value="host.name" v-if="showHostname" />
|
||||
<RandomColorTag class="shrink-0 select-none" :value="container.name" v-if="showContainerName" truncateRight />
|
||||
<LogDate :date="logEntry.date" v-if="showTimestamp" class="shrink-0 select-none" />
|
||||
<LogLevel
|
||||
class="flex select-none"
|
||||
:level="logEntry.level"
|
||||
:position="logEntry instanceof SimpleLogEntry ? logEntry.position : undefined"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry, SimpleLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: LogEntry<any>;
|
||||
}>();
|
||||
const { showHostname, showContainerName } = useLoggingContext();
|
||||
|
||||
const { currentContainer } = useContainerStore();
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const container = currentContainer(toRef(() => logEntry.containerID));
|
||||
const host = computed(() => hosts.value[container.value.host]);
|
||||
</script>
|
||||
@@ -10,7 +10,8 @@ defineProps<{
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
[data-position="start"] {
|
||||
border-radius: 0.5em 0.5em 0 0;
|
||||
height: 70%;
|
||||
@@ -33,7 +34,8 @@ defineProps<{
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
<style lang="postcss">
|
||||
<style>
|
||||
@import "@/main.css" reference;
|
||||
[data-level="debug"],
|
||||
[data-level="trace"] {
|
||||
@apply !bg-purple;
|
||||
@@ -44,14 +46,11 @@ defineProps<{
|
||||
}
|
||||
|
||||
[data-level="error"],
|
||||
[data-level="severe"],
|
||||
[data-level="critical"],
|
||||
[data-level="fatal"] {
|
||||
@apply !bg-red;
|
||||
}
|
||||
|
||||
[data-level="warn"],
|
||||
[data-level="warning"] {
|
||||
[data-level="warn"] {
|
||||
@apply !bg-orange;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<ul
|
||||
class="events group pt-4"
|
||||
:class="{ 'disable-wrap': !softWrap, [size]: true, compact }"
|
||||
v-if="messages.length > 0"
|
||||
>
|
||||
<ul class="events group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
|
||||
<li
|
||||
v-for="item in messages"
|
||||
ref="list"
|
||||
@@ -12,7 +8,7 @@
|
||||
:data-time="item.date.getTime()"
|
||||
class="group/entry"
|
||||
>
|
||||
<component :is="item.getComponent()" :log-entry="item" :show-container-name="showContainerName" />
|
||||
<component :is="item.getComponent()" :log-entry="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -24,7 +20,6 @@ const { loading, progress, currentDate } = useScrollContext();
|
||||
|
||||
const { messages } = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
showContainerName: boolean;
|
||||
}>();
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -58,7 +53,8 @@ useIntersectionObserver(
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.events {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
@@ -71,14 +67,10 @@ useIntersectionObserver(
|
||||
monospace;
|
||||
|
||||
> li {
|
||||
@apply flex break-words px-2 py-1 last:snap-end odd:bg-gray-400/[0.07] has-[.clickable]:cursor-pointer has-[.clickable]:hover:bg-primary/10 md:px-4;
|
||||
@apply has-[.clickable]:hover:bg-primary/10 flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] has-[.clickable]:cursor-pointer md:px-4;
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
|
||||
.jump-context {
|
||||
@apply mr-2 flex items-center font-sans text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@@ -97,6 +89,24 @@ useIntersectionObserver(
|
||||
> li {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
:deep(.tag) {
|
||||
@apply rounded-none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(mark) {
|
||||
@apply bg-secondary inline-block rounded-xs;
|
||||
animation: pops 200ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
0% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:title="t('log_actions.copy_log')"
|
||||
>
|
||||
<span
|
||||
class="rounded bg-slate-800/60 px-1.5 py-1 text-primary hover:bg-slate-700"
|
||||
class="text-primary rounded-sm bg-slate-800/60 px-1.5 py-1 hover:bg-slate-700"
|
||||
@click.prevent="copyLogMessageToClipBoard()"
|
||||
>
|
||||
<carbon:copy-file />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Tag size="small" :std="std">
|
||||
<Tag size="small" :std="std" class="items-start!">
|
||||
{{ std }}
|
||||
</Tag>
|
||||
</template>
|
||||
@@ -12,7 +12,8 @@ defineProps<{
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
[std="stdout"] {
|
||||
@apply text-blue;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<LogList :messages="visibleMessages" :show-container-name="showContainerName" />
|
||||
<LogList :messages="visibleMessages" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -8,7 +8,6 @@ import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
visibleKeys: Map<string[], boolean>;
|
||||
showContainerName: boolean;
|
||||
}>();
|
||||
|
||||
const { messages, visibleKeys } = toRefs(props);
|
||||
@@ -40,4 +39,4 @@ watchEffect(() => {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="postcss"></style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm gap-0.5 px-2">
|
||||
<carbon:circle-solid class="w-2.5 text-red" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="w-2.5 text-blue" v-if="streamConfig.stdout" />
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content z-50 w-52 rounded-box bg-base p-1 shadow">
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
@@ -27,8 +30,8 @@
|
||||
>
|
||||
<div class="flex size-4 gap-0.5">
|
||||
<template v-if="streamConfig.stderr && streamConfig.stdout">
|
||||
<carbon:circle-solid class="w-2 text-red" />
|
||||
<carbon:circle-solid class="w-2 text-blue" />
|
||||
<carbon:circle-solid class="text-red w-2" />
|
||||
<carbon:circle-solid class="text-blue w-2" />
|
||||
</template>
|
||||
</div>
|
||||
{{ $t("toolbar.show-all") }}
|
||||
@@ -42,7 +45,7 @@
|
||||
"
|
||||
>
|
||||
<div class="flex size-4 flex-col gap-1">
|
||||
<carbon:circle-solid class="w-2 text-blue" v-if="!streamConfig.stderr && streamConfig.stdout" />
|
||||
<carbon:circle-solid class="text-blue w-2" v-if="!streamConfig.stderr && streamConfig.stdout" />
|
||||
</div>
|
||||
{{ $t("toolbar.show", { std: "STDOUT" }) }}
|
||||
</a>
|
||||
@@ -55,11 +58,26 @@
|
||||
"
|
||||
>
|
||||
<div class="flex size-4 flex-col gap-1">
|
||||
<carbon:circle-solid class="w-2 text-red" v-if="streamConfig.stderr && !streamConfig.stdout" />
|
||||
<carbon:circle-solid class="text-red w-2" v-if="streamConfig.stderr && !streamConfig.stdout" />
|
||||
</div>
|
||||
{{ $t("toolbar.show", { std: "STDERR" }) }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<a @click="showHostname = !showHostname">
|
||||
<mdi:check class="w-4" v-if="showHostname" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("toolbar.show-hostname") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="showContainerName = !showContainerName">
|
||||
<mdi:check class="w-4" v-if="showContainerName" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ $t("toolbar.show-container-name") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,12 +87,25 @@ const { showSearch } = useSearchFilter();
|
||||
|
||||
const clear = defineEmit();
|
||||
|
||||
const { streamConfig } = useLoggingContext();
|
||||
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();
|
||||
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
|
||||
);
|
||||
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/containers/${containers.value.map((c) => c.host + "~" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
li.line {
|
||||
@apply h-px bg-base-content/20;
|
||||
@apply bg-base-content/20 h-px;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="tag grid w-40 overflow-hidden rounded-sm 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" :class="truncateRight ? '[direction:rtl]' : ''">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
const colors = [
|
||||
"hsl(200, 85%, 65%)", // Vibrant Sky Blue
|
||||
"hsl(150, 85%, 65%)", // Vibrant Mint
|
||||
"hsl(300, 85%, 65%)", // Vibrant Purple
|
||||
"hsl(25, 85%, 65%)", // Vibrant Peach
|
||||
"hsl(270, 85%, 65%)", // Vibrant Lavender
|
||||
"hsl(340, 85%, 65%)", // Vibrant Rose
|
||||
"hsl(170, 85%, 65%)", // Vibrant Aqua
|
||||
"hsl(50, 85%, 65%)", // Vibrant Yellow
|
||||
"hsl(235, 85%, 65%)", // Vibrant Periwinkle
|
||||
"hsl(10, 85%, 65%)", // Vibrant Coral
|
||||
"hsl(180, 85%, 65%)", // Vibrant Turquoise
|
||||
"hsl(320, 85%, 65%)", // Vibrant Orchid
|
||||
"hsl(90, 85%, 65%)", // Vibrant Lime
|
||||
"hsl(260, 85%, 65%)", // Vibrant Amethyst
|
||||
"hsl(30, 85%, 65%)", // Vibrant Orange
|
||||
"hsl(210, 85%, 65%)", // Vibrant Ocean
|
||||
"hsl(290, 85%, 65%)", // Vibrant Mauve
|
||||
"hsl(120, 85%, 65%)", // Vibrant Spring
|
||||
"hsl(350, 85%, 65%)", // Vibrant Strawberry
|
||||
"hsl(190, 85%, 65%)", // Vibrant Azure
|
||||
] as const;
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
const { value, truncateRight = false } = defineProps<{
|
||||
value: string;
|
||||
truncateRight?: boolean;
|
||||
}>();
|
||||
|
||||
const color = computed(() => colors[Math.abs(hashCode(value)) % colors.length]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.random-color {
|
||||
background-color: v-bind(color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<table class="table-zebra table-pin-rows table-md table" v-if="!loading">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in table" :key="row">
|
||||
<td v-for="column in columns" :key="column">{{ row[column] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table-md table animate-pulse" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="_ in 3">
|
||||
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-50"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="_ in 9">
|
||||
<td v-for="_ in 3">
|
||||
<div class="bg-base-content/50 h-4 w-20 opacity-20"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { type Table } from "@apache-arrow/esnext-esm";
|
||||
|
||||
const { loading, table } = defineProps<{
|
||||
loading: boolean;
|
||||
table: Table<Record<string, any>>;
|
||||
}>();
|
||||
|
||||
const columns = computed(() => (table.numRows > 0 ? Object.keys(table.get(0) as Record<string, any>) : []));
|
||||
</script>
|
||||
@@ -1,19 +1,15 @@
|
||||
<template>
|
||||
<div class="relative flex w-full items-start gap-x-2">
|
||||
<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" />
|
||||
<LogItem :logEntry>
|
||||
<div
|
||||
class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap"
|
||||
class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap"
|
||||
v-html="linkify(colorize(logEntry.message))"
|
||||
></div>
|
||||
<LogMessageActions
|
||||
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
|
||||
class="absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100"
|
||||
:message="() => decodeXML(stripAnsi(logEntry.message))"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</div>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
@@ -23,22 +19,24 @@ import stripAnsi from "strip-ansi";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--base-content-color)",
|
||||
bg: "var(--base-color)",
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
const { showContainerName = false } = defineProps<{
|
||||
defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/g;
|
||||
const urlPattern =
|
||||
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_+.~#?&/=]*/g;
|
||||
const linkify = (text: string) =>
|
||||
text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.log-wrapper > :deep(a) {
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.log-wrapper :deep(a) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="my-4 flex-1 text-center">
|
||||
<div class="relative">
|
||||
<ZigZag class="absolute inset-0 mt-2" />
|
||||
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">
|
||||
<span class="bg-base-200 relative px-4 py-2 font-bold whitespace-pre-wrap">
|
||||
{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<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">
|
||||
<div class="hover:text-secondary relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
|
||||
<div class="border-primary hidden overflow-hidden rounded-xs border px-px pt-1 pb-px md:flex">
|
||||
<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="bg-base-200 inline-flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
|
||||
<div class="font-light uppercase">{{ label }}</div>
|
||||
<div class="select-none font-bold">
|
||||
{{ mouseOver ? selectedPoint?.value ?? selectedPoint?.y ?? statValue : statValue }}
|
||||
<div class="font-bold select-none">
|
||||
{{ mouseOver ? (selectedPoint?.value ?? selectedPoint?.y ?? statValue) : statValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<svg :width="width" :height="height" @mousemove="onMove" class="group">
|
||||
<path :d="path" class="fill-primary" />
|
||||
<line :x1="lineX" y1="0" :x2="lineX" :y2="height" class="invisible stroke-secondary stroke-2 group-hover:visible" />
|
||||
<line :x1="lineX" y1="0" :x2="lineX" :y2="height" class="stroke-secondary invisible stroke-2 group-hover:visible" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<EventSource ref="source" #default="{ messages }" :stream-source="streamSource" :entity="entity">
|
||||
<LogViewer :messages="messages" :visible-keys="visibleKeys" :show-container-name="showContainerName" />
|
||||
<LogViewer :messages="messages" :visible-keys="visibleKeys" />
|
||||
</EventSource>
|
||||
</template>
|
||||
|
||||
@@ -9,10 +9,9 @@ import EventSource from "@/components/LogViewer/EventSource.vue";
|
||||
import { LogStreamSource } from "@/composable/eventStreams";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { streamSource, visibleKeys, showContainerName, entity } = defineProps<{
|
||||
const { streamSource, visibleKeys, entity } = defineProps<{
|
||||
streamSource: (t: Ref<T>) => LogStreamSource;
|
||||
visibleKeys: Map<string[], boolean>;
|
||||
showContainerName: boolean;
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" data-v-a49e52d4="" 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>
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,17 +21,18 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" data-v-a49e52d4="" 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>
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,17 +41,18 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" data-v-a49e52d4="" 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>
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,11 +60,6 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
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>
|
||||
<!--v-if-->"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > should parse messages 1`] = `
|
||||
SimpleLogEntry {
|
||||
"_message": "This is a message.",
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<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="@container flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<octicon:container-24 />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ containers.length }} containers</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="mobile-hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -17,7 +18,6 @@
|
||||
:stream-source="useMergedStream"
|
||||
:entity="containers"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
@@ -37,5 +37,5 @@ const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
const { allContainersById, ready } = storeToRefs(containerStore);
|
||||
const containers = computed(() => ids.map((id) => allContainersById.value[id]));
|
||||
|
||||
provideLoggingContext(containers);
|
||||
provideLoggingContext(containers, { showContainerName: true, showHostname: false });
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="show && (delayedShow || glopbalShow)"
|
||||
class="fixed z-50 rounded border border-base-content/20 bg-base-lighter p-4 shadow"
|
||||
class="border-base-content/20 bg-base-100 fixed z-50 rounded-sm border p-4 shadow-sm"
|
||||
ref="content"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
@@ -45,7 +45,7 @@ useEventListener(() => el.value?.nextElementSibling, "mouseenter", onMouseEnter)
|
||||
useEventListener(() => el.value?.nextElementSibling, "mouseleave", onMouseLeave);
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<ul class="space-y-4 p-2">
|
||||
<li v-for="release in releases" v-if="releases?.length">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<carbon:warning class="self-center stroke-orange" v-if="release.breaking > 0" />
|
||||
<a :href="release.htmlUrl" class="link-primary text-lg font-bold" target="_blank" rel="noreferrer noopener">
|
||||
{{ 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">
|
||||
{{ $t("releases.latest") }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/80">
|
||||
{{ summary(release) }}
|
||||
</div>
|
||||
</li>
|
||||
<li v-else>
|
||||
<div class="text-sm text-base-content/80">
|
||||
{{ $t("releases.no_releases") }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { releases, latest } = useReleases();
|
||||
const { t } = useI18n();
|
||||
|
||||
function summary(release: { features: number; bugFixes: number; breaking: number }) {
|
||||
if (release.features > 0 && release.bugFixes > 0 && release.breaking > 0) {
|
||||
return t("releases.three_parts", {
|
||||
first: t("releases.breaking", { count: release.breaking }),
|
||||
second: t("releases.features", { count: release.features }),
|
||||
third: t("releases.bugFixes", { count: release.bugFixes }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.features > 0 && release.bugFixes > 0) {
|
||||
return t("releases.two_parts", {
|
||||
first: t("releases.features", { count: release.features }),
|
||||
second: t("releases.bugFixes", { count: release.bugFixes }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.features > 0 && release.breaking > 0) {
|
||||
return t("releases.two_parts", {
|
||||
first: t("releases.features", { count: release.features }),
|
||||
second: t("releases.breaking", { count: release.breaking }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.bugFixes > 0 && release.breaking > 0) {
|
||||
return t("releases.two_parts", {
|
||||
first: t("releases.bugFixes", { count: release.bugFixes }),
|
||||
second: t("releases.breaking", { count: release.breaking }),
|
||||
});
|
||||
}
|
||||
|
||||
if (release.features > 0) {
|
||||
return t("releases.features", { count: release.features });
|
||||
}
|
||||
|
||||
if (release.bugFixes > 0) {
|
||||
return t("releases.bugFixes", { count: release.bugFixes });
|
||||
}
|
||||
|
||||
if (release.breaking > 0) {
|
||||
return t("releases.breaking", { count: release.breaking });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="inline-flex flex-col items-end gap-2" ref="root" v-show="!autoHide || show">
|
||||
<div class="relative inline-block">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" :class="{ indeterminate }">
|
||||
<circle r="44" cx="50" cy="50" class="fill-base-darker stroke-primary" />
|
||||
<circle r="44" cx="50" cy="50" class="fill-base-300 stroke-primary" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center font-light">
|
||||
<span class="text-4xl">
|
||||
@@ -12,7 +12,7 @@
|
||||
<span> % </span>
|
||||
</div>
|
||||
</div>
|
||||
<DistanceTime :date="date" class="whitespace-nowrap text-sm" />
|
||||
<DistanceTime :date="date" class="text-sm whitespace-nowrap" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -41,7 +41,7 @@ watch(
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
svg {
|
||||
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.2));
|
||||
margin-top: 5px;
|
||||
|
||||
@@ -2,39 +2,33 @@
|
||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="sticky top-[70px] z-[2] border-b border-base-content/10 bg-base py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
|
||||
class="border-base-content/10 bg-base-200 sticky top-[55px] z-2 border-b py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
|
||||
<div class="invisible relative md:visible" v-show="scrollContext.paused">
|
||||
<div class="absolute right-44 top-4">
|
||||
<div class="absolute top-4 right-44">
|
||||
<ScrollProgress
|
||||
:indeterminate="loadingMore"
|
||||
:auto-hide="!loadingMore"
|
||||
:progress="scrollContext.progress"
|
||||
:date="scrollContext.currentDate"
|
||||
class="!fixed z-10 min-w-40"
|
||||
class="fixed! z-10 min-w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="scrollableContent">
|
||||
<slot></slot>
|
||||
<div v-if="scrollContext.loading" class="m-4 text-center">
|
||||
<span class="loading loading-ring loading-md text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="animate-background h-1 w-1/2 bg-gradient-radial from-primary to-transparent to-75%"
|
||||
v-show="!scrollContext.paused && !scrollContext.loading"
|
||||
></div>
|
||||
|
||||
<div ref="scrollObserver" class="h-px"></div>
|
||||
</main>
|
||||
|
||||
<div class="mr-16 text-right">
|
||||
<transition name="fade">
|
||||
<button
|
||||
class="transition-colorsblur-xs dark btn btn-primary fixed bottom-8 rounded p-3 text-primary-content shadow"
|
||||
class="transition-colorsblur-xs dark btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm"
|
||||
:class="hasMore ? 'btn-secondary animate-bounce-fast text-secondary-content' : ''"
|
||||
@click="scrollToBottom()"
|
||||
v-show="scrollContext.paused"
|
||||
@@ -83,7 +77,7 @@ function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
hasMore.value = false;
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
@@ -93,20 +87,6 @@ function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.animate-background {
|
||||
animation: gradient-animation 3s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-animation {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
ref="container"
|
||||
:style="style"
|
||||
>
|
||||
<div
|
||||
class="input input-primary flex h-auto items-center !shadow-lg"
|
||||
:class="!isValidQuery ? 'input-warning' : ''"
|
||||
>
|
||||
<div class="input input-primary flex items-center shadow-lg" :class="!isValidQuery ? 'input-warning' : ''">
|
||||
<mdi:magnify />
|
||||
<input
|
||||
class="input input-ghost w-72 flex-1"
|
||||
@@ -57,7 +54,7 @@ onMounted(() => {
|
||||
onUnmounted(() => resetSearch());
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
<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="@container flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<ph:stack-simple />
|
||||
<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">
|
||||
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
|
||||
{{ $t("label.container", service.containers.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="service.containers" />
|
||||
<MultiContainerActionToolbar class="mobile-hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -20,7 +21,6 @@
|
||||
:stream-source="useServiceStream"
|
||||
:entity="service"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
@@ -41,5 +41,8 @@ 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));
|
||||
provideLoggingContext(
|
||||
toRef(() => service.value.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
<template>
|
||||
<div v-if="ready" data-testid="side-menu">
|
||||
<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>
|
||||
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 flex-col">
|
||||
<Carousel v-model="selectedCard" class="flex-1">
|
||||
<CarouselItem :title="$t('label.host-menu')" id="host">
|
||||
<HostMenu />
|
||||
</template>
|
||||
<template #right>
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.group-menu')" v-if="customGroups.length > 0" id="group">
|
||||
<GroupMenu />
|
||||
</CarouselItem>
|
||||
<CarouselItem :title="$t('label.swarm-menu')" v-if="services.length > 0" id="swarm">
|
||||
<SwarmMenu />
|
||||
</template>
|
||||
</SlideTransition>
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
</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>
|
||||
<div class="bg-base-content/50 h-3 w-full rounded-full opacity-50" v-for="_ in 9"></div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const route = useRoute();
|
||||
const swarmStore = useSwarmStore();
|
||||
const { services, customGroups } = storeToRefs(swarmStore);
|
||||
const selectedCard = ref<"host" | "swarm" | "group">("host");
|
||||
|
||||
const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (to.meta.swarmMode) {
|
||||
showSwarm.value = true;
|
||||
} else if (to.meta.containerMode) {
|
||||
showSwarm.value = false;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
if (route.meta.menu && ["host", "swarm", "group"].includes(route.meta.menu as string)) {
|
||||
selectedCard.value = route.meta.menu as "host" | "swarm" | "group";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss"></style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<aside class="fixed h-screen w-[inherit] overflow-auto p-3" data-testid="navigation">
|
||||
<aside class="fixed flex h-screen w-[inherit] flex-col gap-4 p-3" data-testid="navigation">
|
||||
<h1>
|
||||
<router-link :to="{ name: '/' }">
|
||||
<svg class="h-14 w-28 fill-secondary">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
<LogoWithText class="logo [&_.secondary-fill]:fill-secondary [&_.content-fill]:fill-base-content h-16 w-40" />
|
||||
</router-link>
|
||||
|
||||
<small class="mb-4 block text-xs font-light" v-if="hostname">
|
||||
@@ -13,7 +11,7 @@
|
||||
</h1>
|
||||
|
||||
<button
|
||||
class="input input-sm mt-4 inline-flex cursor-pointer items-center gap-2 font-light hover:border-primary"
|
||||
class="input input-sm hover:border-primary inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
|
||||
@click="$emit('search')"
|
||||
:title="$t('tooltip.search')"
|
||||
data-testid="search"
|
||||
@@ -23,10 +21,13 @@
|
||||
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
|
||||
</button>
|
||||
|
||||
<side-menu class="mt-4"></side-menu>
|
||||
<SideMenu class="mt-2 flex-1" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogoWithText from "@/logo-text.svg";
|
||||
const { hostname } = config;
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
<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="@container flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<ph:stack />
|
||||
<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">
|
||||
<Tag class="hidden font-mono max-md:hidden @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 class="hidden font-mono max-md:hidden @3xl:block" size="small">
|
||||
{{ $t("label.service", stack.services.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
|
||||
<MultiContainerActionToolbar class="mobile-hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -23,7 +24,6 @@
|
||||
:stream-source="useStackStream"
|
||||
:entity="stack"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
:show-container-name="true"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
@@ -42,5 +42,8 @@ const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
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));
|
||||
provideLoggingContext(
|
||||
toRef(() => stack.value.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<ul class="menu p-0 text-[0.95rem]">
|
||||
<ul class="menu w-full p-0 text-[0.95rem]">
|
||||
<li v-for="{ name, services } in stacks" :key="name">
|
||||
<details open>
|
||||
<summary class="font-light text-base-content/80">
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:stack />
|
||||
{{ name }}
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
</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"
|
||||
>
|
||||
<router-link :to="{ name: '/service/[name]', params: { name: service.name } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">
|
||||
{{ service.name }}
|
||||
@@ -31,18 +28,15 @@
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li v-if="serivcesWithoutStacks.length > 0">
|
||||
<li v-if="servicesWithoutStacks.length > 0">
|
||||
<details open>
|
||||
<summary class="font-light text-base-content/80">
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<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"
|
||||
>
|
||||
<li v-for="service in servicesWithoutStacks" :key="service.name">
|
||||
<router-link :to="{ name: '/service/[name]', params: { name: service.name } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">
|
||||
{{ service.name }}
|
||||
@@ -52,37 +46,13 @@
|
||||
</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 { stacks, services } = storeToRefs(store);
|
||||
|
||||
const serivcesWithoutStacks = computed(() => services.value.filter((service) => !service.stack));
|
||||
const servicesWithoutStacks = computed(() => services.value.filter((service) => !service.stack));
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.menu {
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<aside>
|
||||
<header class="flex items-center gap-4">
|
||||
<material-symbols:terminal class="size-8" />
|
||||
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
|
||||
<h2 class="text-sm">Started <DistanceTime :date="container.created" /></h2>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-2">
|
||||
<section>
|
||||
<div ref="host" class="shell"></div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
const { container, action } = defineProps<{ container: Container; action: "attach" | "exec" }>();
|
||||
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
const { WebLinksAddon } = await import("@xterm/addon-web-links");
|
||||
|
||||
const host = useTemplateRef<HTMLDivElement>("host");
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
});
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
terminal.open(host.value!);
|
||||
terminal.resize(100, 40);
|
||||
ws = new WebSocket(withBase(`/api/hosts/${container.host}/containers/${container.id}/${action}`));
|
||||
ws.onopen = () => {
|
||||
terminal.writeln(`Attached to ${container.name} 🚀`);
|
||||
if (action === "attach") {
|
||||
ws?.send("\r");
|
||||
}
|
||||
terminal.onData((data) => {
|
||||
ws?.send(data);
|
||||
});
|
||||
terminal.focus();
|
||||
};
|
||||
ws.onmessage = (event) => terminal.write(event.data);
|
||||
ws.addEventListener("close", () => {
|
||||
terminal.writeln("⚠️ Connection closed");
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
console.log("Closing WebSocket");
|
||||
terminal.dispose();
|
||||
ws?.close();
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
|
||||
.shell {
|
||||
& :deep(.terminal) {
|
||||
@apply overflow-hidden rounded border-1 p-2;
|
||||
&:is(.focus) {
|
||||
@apply border-primary;
|
||||
}
|
||||
}
|
||||
|
||||
& :deep(.xterm-viewport) {
|
||||
@apply bg-base-200!;
|
||||
}
|
||||
|
||||
& :deep(.xterm-rows) {
|
||||
@apply text-base-content;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col gap-2">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-auto overscroll-y-contain">
|
||||
<div
|
||||
ref="container"
|
||||
class="scrollbar-hide flex shrink-0 grow snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth"
|
||||
>
|
||||
<component v-for="(card, index) in providedCards" :key="index" :is="card" ref="cards" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-center text-sm font-thin">
|
||||
{{ cards?.[activeIndex].title }}
|
||||
</h3>
|
||||
<div class="flex flex-none justify-center gap-2" v-if="providedCards.length > 1">
|
||||
<button
|
||||
v-for="(c, index) in providedCards"
|
||||
:key="c.props?.id"
|
||||
@click="scrollToItem(index)"
|
||||
:class="[
|
||||
'size-2 cursor-pointer rounded-full transition-all duration-700',
|
||||
activeIndex === index ? 'bg-primary scale-125' : 'bg-base-content/50 hover:bg-base-content',
|
||||
]"
|
||||
:aria-label="c.props?.title"
|
||||
:title="c.props?.title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CarouselItem from "./CarouselItem.vue";
|
||||
const container = useTemplateRef<HTMLDivElement>("container");
|
||||
const activeIndex = ref(0);
|
||||
const activeId = defineModel<string>();
|
||||
const slots = defineSlots<{ default(): VNode[] }>();
|
||||
const providedCards = computed(() => slots.default().filter(({ type }) => type === CarouselItem));
|
||||
const cards = useTemplateRef<InstanceType<typeof CarouselItem>[]>("cards");
|
||||
|
||||
const scrollToItem = (index: number) => {
|
||||
cards.value?.[index].$el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
inline: "start",
|
||||
});
|
||||
};
|
||||
|
||||
const { pause, resume } = watchPausable(activeId, (v) => {
|
||||
if (activeId.value) {
|
||||
const index = cards.value?.map((c) => c.id).indexOf(activeId.value) ?? -1;
|
||||
if (index !== -1) {
|
||||
console.log("watching", activeId.value);
|
||||
scrollToItem(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watchOnce(cards, () => {
|
||||
if (activeId.value) {
|
||||
const index = cards.value?.map((c) => c.id).indexOf(activeId.value) ?? -1;
|
||||
if (index !== -1) {
|
||||
scrollToItem(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useIntersectionObserver(
|
||||
cards as Ref<InstanceType<typeof CarouselItem>[]>,
|
||||
(entries) => {
|
||||
entries.forEach(({ isIntersecting, target }) => {
|
||||
if (isIntersecting) {
|
||||
const index = cards.value?.map((c) => c.$el).indexOf(target as HTMLDivElement) ?? -1;
|
||||
if (index !== -1) {
|
||||
pause();
|
||||
activeIndex.value = index;
|
||||
activeId.value = cards.value?.[index].id;
|
||||
nextTick(() => resume());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: container,
|
||||
threshold: 0.5,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="w-full min-w-full shrink-0 snap-start snap-always p-0.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { id, title } = defineProps<{ id: string; title?: string }>();
|
||||
|
||||
defineExpose({
|
||||
id,
|
||||
title,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="inline-flex gap-2">
|
||||
<time :datetime="date.toISOString()" class="mobile-hidden">{{ dateStr }}</time>
|
||||
<time :datetime="date.toISOString()" class="max-md:hidden">{{ dateStr }}</time>
|
||||
<time :datetime="date.toISOString()">{{ timeStr }}</time>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</label>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content z-50 mt-1 min-w-52 rounded-box border border-base-content/20 bg-base p-2 shadow"
|
||||
class="dropdown-content rounded-box border-base-content/20 bg-base-200 z-50 mt-1 min-w-52 border p-2 shadow-sm"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
<summary class="btn btn-primary flex-nowrap" v-bind="$attrs">
|
||||
<slot name="trigger"> {{ label }} <carbon:caret-down /></slot>
|
||||
</summary>
|
||||
<ul class="menu dropdown-content z-50 mt-1 w-52 rounded-box border border-base-content/20 bg-base p-2 shadow">
|
||||
<ul
|
||||
class="menu dropdown-content rounded-box border-base-content/20 bg-base-200 z-50 mt-1 w-52 border p-2 shadow-sm"
|
||||
>
|
||||
<slot>
|
||||
<li v-for="item in options">
|
||||
<a @click="modelValue = item.value">
|
||||
<a @click="update(item.value as T)">
|
||||
<mdi:check class="w-4" v-if="modelValue == item.value" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ item.label }}
|
||||
@@ -24,17 +26,19 @@ type DropdownItem = {
|
||||
value: T;
|
||||
};
|
||||
|
||||
const { modelValue } = defineModels<{
|
||||
modelValue: T;
|
||||
}>();
|
||||
const model = defineModel<T>();
|
||||
|
||||
const { options = [], defaultLabel = "" } = defineProps<{
|
||||
options?: DropdownItem[];
|
||||
const { options, defaultLabel = "" } = defineProps<{
|
||||
options: DropdownItem[];
|
||||
defaultLabel?: string;
|
||||
}>();
|
||||
|
||||
const label = computed(() => options.find((item) => item.value === modelValue.value)?.label ?? defaultLabel);
|
||||
const label = computed(() => options.find((item) => item.value === model.value)?.label ?? defaultLabel);
|
||||
const details = ref<HTMLElement | null>(null);
|
||||
const close = () => details.value?.removeAttribute("open");
|
||||
watch(modelValue, () => close());
|
||||
|
||||
const update = (value: T) => {
|
||||
model.value = value;
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<mdi:satellite-variant v-if="type == 'agent'" />
|
||||
<ph:globe-simple v-else-if="type == 'remote'" />
|
||||
<mdi:hexagon-multiple v-else-if="type == 'swarm'" />
|
||||
<carbon:logo-kubernetes v-else-if="type == 'k8s'" />
|
||||
<ph:computer-tower v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="animate-background h-1 w-1/2 bg-radial to-transparent to-75%" :class="colorClass"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const { color = "primary" } = defineProps<{ color: "primary" | "error" | "secondary" }>();
|
||||
|
||||
const colorClass = computed(() => {
|
||||
switch (color) {
|
||||
case "primary":
|
||||
return "from-primary";
|
||||
case "error":
|
||||
return "from-error";
|
||||
case "secondary":
|
||||
return "from-secondary";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-background {
|
||||
animation: gradient-animation 3s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-animation {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label class="label cursor-pointer gap-4">
|
||||
<div class="label-text"><slot name="label" /></div>
|
||||
<label class="label text-base-content cursor-pointer gap-4">
|
||||
<div class="flex-1"><slot name="label" /></div>
|
||||
<slot name="input" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<nav class="fixed top-0 z-10 w-full border-b border-base-content/20 bg-base p-2" data-testid="navigation">
|
||||
<nav class="border-base-content/20 bg-base-200 fixed top-0 z-10 w-full border-b p-2" data-testid="navigation">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: '/' }">
|
||||
<svg class="h-14 w-28 fill-secondary">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
<Logo class="logo [&_.secondary-fill]:fill-secondary h-8" />
|
||||
</router-link>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
@@ -20,14 +18,15 @@
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-show="show" class="h-[calc(100vh-60px)] overflow-auto">
|
||||
<SideMenu />
|
||||
<div v-show="show" class="flex h-[calc(100svh-55px)]">
|
||||
<SideMenu class="flex-1" />
|
||||
</div>
|
||||
</transition>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Logo from "@/logo.svg";
|
||||
const route = useRoute();
|
||||
|
||||
const show = ref(false);
|
||||
@@ -35,18 +34,15 @@ watch(route, () => {
|
||||
show.value = false;
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
li.exited {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
}
|
||||
|
||||
.fade-enter-active .menu,
|
||||
.fade-leave-active .menu {
|
||||
.fade-enter-active > div,
|
||||
.fade-leave-active > div {
|
||||
@apply transition-transform;
|
||||
}
|
||||
|
||||
@@ -55,8 +51,8 @@ li.exited {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.fade-enter-from .menu,
|
||||
.fade-leave-to .menu {
|
||||
@apply -translate-y-2;
|
||||
.fade-enter-from > div,
|
||||
.fade-leave-to > div {
|
||||
@apply -translate-y-10;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<dialog ref="panel" class="modal-right modal items-start outline-none backdrop:bg-none">
|
||||
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
|
||||
<div class="modal-box" :width="width">
|
||||
<form method="dialog">
|
||||
<button class="swap swap-rotate absolute right-4 top-4 outline-none hover:swap-active">
|
||||
<button class="swap hover:swap-active absolute top-4 right-4 outline-hidden">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
@@ -32,9 +32,11 @@ defineExpose({
|
||||
|
||||
useEventListener(panel, "close", () => (open.value = false));
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
|
||||
.modal-right :where(.modal-box) {
|
||||
@apply fixed right-0 h-lvh max-h-screen translate-x-24 scale-100 rounded-none bg-base-lighter shadow-none;
|
||||
@apply bg-base-100 fixed right-0 h-lvh max-h-screen translate-x-24 scale-100 rounded-none shadow-none;
|
||||
|
||||
&[width="md"] {
|
||||
@apply max-w-3xl;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<script lang="ts" setup>
|
||||
const { slideRight } = defineProps<{ slideRight: boolean }>();
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em]" :size="size">
|
||||
<div
|
||||
class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem]"
|
||||
:size="size"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,9 +10,3 @@
|
||||
<script lang="ts" setup>
|
||||
const { size = undefined } = defineProps<{ size?: "small" | undefined }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
[size="small"] {
|
||||
@apply text-xs;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,4 +40,4 @@ const cancel = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss"></style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="toast toast-end whitespace-normal max-md:m-0 max-md:w-full">
|
||||
<div
|
||||
class="alert max-w-xl max-md:rounded-none"
|
||||
v-for="{ toast, options: { timed } } in toasts"
|
||||
:key="toast.id"
|
||||
:class="{
|
||||
'alert-error': toast.type === 'error',
|
||||
'alert-info': toast.type === 'info',
|
||||
'alert-warning': toast.type === 'warning',
|
||||
}"
|
||||
>
|
||||
<carbon:information class="size-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
|
||||
<div v-html="toast.message" class="[&>a]:underline"></div>
|
||||
</div>
|
||||
<div>
|
||||
<TimedButton
|
||||
v-if="timed"
|
||||
class="btn-primary btn-sm"
|
||||
:duration="timed"
|
||||
@finished="
|
||||
removeToast(toast.id);
|
||||
toast.action?.handler();
|
||||
"
|
||||
@cancelled="removeToast(toast.id)"
|
||||
>
|
||||
{{ toast.action?.label }}
|
||||
</TimedButton>
|
||||
<button class="btn btn-circle btn-xs" @click="removeToast(toast.id)" v-else>
|
||||
<mdi:close />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const { toasts, removeToast } = useToast();
|
||||
</script>
|
||||
@@ -4,13 +4,11 @@
|
||||
<slot />
|
||||
</template>
|
||||
<template #input>
|
||||
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" />
|
||||
<input type="checkbox" class="toggle toggle-primary" v-model="modelValue" v-bind="$attrs" />
|
||||
</template>
|
||||
</LabeledInput>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { modelValue } = defineModels<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
const modelValue = defineModel<boolean>();
|
||||
</script>
|
||||
|
||||
@@ -26,6 +26,10 @@ export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
return useLogStream(url, loadMoreUrl);
|
||||
}
|
||||
|
||||
export function useHostStream(host: Ref<Host>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/hosts/${host.value.id}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/stacks/${stack.value.name}/logs/stream`));
|
||||
}
|
||||
@@ -52,6 +56,9 @@ export type LogStreamSource = ReturnType<typeof useLogStream>;
|
||||
function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
|
||||
const buffer: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
|
||||
const opened = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const { paused: scrollingPaused } = useScrollContext();
|
||||
|
||||
function flushNow() {
|
||||
@@ -104,37 +111,43 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const { streamConfig, hasComplexLogs } = useLoggingContext();
|
||||
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
|
||||
|
||||
const params = computed(() => {
|
||||
const params = Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {} as Record<string, string>);
|
||||
|
||||
if (isSearching.value) {
|
||||
params["filter"] = debouncedSearchFilter.value;
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
|
||||
return params;
|
||||
});
|
||||
|
||||
const urlWithParams = computed(() => withBase(`${url.value}?${new URLSearchParams(params.value).toString()}`));
|
||||
const urlWithParams = computed(() => withBase(`${url.value}?${params.value.toString()}`));
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
close();
|
||||
if (clear) clearMessages();
|
||||
opened.value = false;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
es = new EventSource(urlWithParams.value);
|
||||
es.addEventListener("container-event", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string };
|
||||
const event = JSON.parse((e as MessageEvent).data) as {
|
||||
actorId: string;
|
||||
name: "container-stopped" | "container-started";
|
||||
time: string;
|
||||
};
|
||||
const containerEvent = new ContainerEventLogEntry(
|
||||
event.name == "container-started" ? "Container started" : "Container stopped",
|
||||
event.actorId,
|
||||
new Date(),
|
||||
event.name as "container-stopped" | "container-started",
|
||||
new Date(event.time),
|
||||
event.name,
|
||||
);
|
||||
|
||||
buffer.value = [...buffer.value, containerEvent];
|
||||
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
|
||||
@@ -150,7 +163,14 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
es.onerror = () => clearMessages();
|
||||
es.onerror = () => {
|
||||
error.value = true;
|
||||
};
|
||||
es.onopen = () => {
|
||||
loading.value = false;
|
||||
opened.value = true;
|
||||
error.value = false;
|
||||
};
|
||||
}
|
||||
|
||||
watch(urlWithParams, () => connect(), { immediate: true });
|
||||
@@ -162,6 +182,7 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
if (isLoadingMore.value) return;
|
||||
|
||||
const to = messages.value[0].date;
|
||||
const lastSeenId = messages.value[0].id;
|
||||
const last = messages.value[Math.min(messages.value.length - 1, 300)].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
@@ -170,10 +191,15 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
const signal = abortController.signal;
|
||||
isLoadingMore.value = true;
|
||||
try {
|
||||
const moreParams = { ...params.value, from: from.toISOString(), to: to.toISOString(), minimum: "100" };
|
||||
const urlWithMoreParams = computed(() =>
|
||||
withBase(`${loadMoreUrl.value}?${new URLSearchParams(moreParams).toString()}`),
|
||||
);
|
||||
const urlWithMoreParams = computed(() => {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
loadMoreParams.append("minimum", "100");
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
|
||||
return withBase(`${loadMoreUrl.value}?${loadMoreParams.toString()}`);
|
||||
});
|
||||
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
|
||||
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
|
||||
stopWatcher();
|
||||
@@ -194,7 +220,20 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
|
||||
onScopeDispose(() => close());
|
||||
|
||||
watch(messages, () => (hasComplexLogs.value = messages.value.some((m) => m instanceof ComplexLogEntry)));
|
||||
watch(messages, () => {
|
||||
if (messages.value.length > 1) {
|
||||
hasComplexLogs.value = messages.value.some((m) => m instanceof ComplexLogEntry);
|
||||
}
|
||||
});
|
||||
|
||||
return { messages, loadOlderLogs, isLoadingMore, hasComplexLogs };
|
||||
return {
|
||||
messages,
|
||||
loadOlderLogs,
|
||||
isLoadingMore,
|
||||
hasComplexLogs,
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
eventSourceURL: urlWithParams,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import { Level } from "@/models/LogEntry";
|
||||
|
||||
type LogContext = {
|
||||
streamConfig: { stdout: boolean; stderr: boolean };
|
||||
containers: Container[];
|
||||
loadingMore: boolean;
|
||||
hasComplexLogs: boolean;
|
||||
levels: Set<Level>;
|
||||
showContainerName: boolean;
|
||||
showHostname: boolean;
|
||||
};
|
||||
|
||||
export const allLevels: Level[] = ["info", "debug", "warn", "error", "fatal", "trace", "unknown"];
|
||||
|
||||
// export for testing
|
||||
export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const stdout = searchParams.has("stdout") ? searchParams.get("stdout") === "true" : true;
|
||||
const stderr = searchParams.has("stderr") ? searchParams.get("stderr") === "true" : true;
|
||||
|
||||
export const provideLoggingContext = (containers: Ref<Container[]>) => {
|
||||
export const provideLoggingContext = (
|
||||
containers: Ref<Container[]>,
|
||||
{ showContainerName = false, showHostname = false } = {},
|
||||
) => {
|
||||
provide(
|
||||
loggingContextKey,
|
||||
reactive({
|
||||
@@ -21,6 +30,9 @@ export const provideLoggingContext = (containers: Ref<Container[]>) => {
|
||||
containers,
|
||||
loadingMore: false,
|
||||
hasComplexLogs: false,
|
||||
levels: new Set<Level>(allLevels),
|
||||
showContainerName,
|
||||
showHostname,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -33,6 +45,9 @@ export const useLoggingContext = () => {
|
||||
containers: [],
|
||||
loadingMore: false,
|
||||
hasComplexLogs: false,
|
||||
levels: new Set<Level>(allLevels),
|
||||
showContainerName: false,
|
||||
showHostname: false,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useProfileStorage<K extends keyof Profile>(
|
||||
if (transformer) {
|
||||
storage.value = transformer.from(config.profile[key]);
|
||||
} else if (storage.value instanceof Set && config.profile[key] instanceof Array) {
|
||||
storage.value = new Set([...(config.profile[key] as Iterable<any>)]) as unknown as NonNullable<Profile[K]>;
|
||||
storage.value = new Set(config.profile[key] as Iterable<any>) as unknown as NonNullable<Profile[K]>;
|
||||
} else if (config.profile[key] instanceof Array) {
|
||||
storage.value = config.profile[key] as NonNullable<Profile[K]>;
|
||||
} else if (config.profile[key] instanceof Object) {
|
||||
|
||||
@@ -4,36 +4,55 @@ type Toast = {
|
||||
title?: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
action?: {
|
||||
label: string;
|
||||
handler: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ToastOptions = {
|
||||
expire?: number;
|
||||
once?: boolean;
|
||||
timed?: number;
|
||||
};
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
const toasts = ref<
|
||||
{
|
||||
toast: Toast;
|
||||
options: ToastOptions;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const showToast = (
|
||||
toast: Omit<Toast, "id" | "createdAt"> & { id?: string },
|
||||
{ expire = -1, once = false }: ToastOptions = { expire: -1, once: false },
|
||||
{ expire = -1, once = false, timed }: ToastOptions = { expire: -1, once: false },
|
||||
) => {
|
||||
if (once && toasts.value.some((t) => t.id === toast.id)) {
|
||||
if (once && !toast.id) {
|
||||
throw new Error("Toast id is required when once is true");
|
||||
}
|
||||
if (once && toasts.value.some((t) => t.toast.id === toast.id)) {
|
||||
return;
|
||||
}
|
||||
toasts.value.push({
|
||||
|
||||
const toastWithId = {
|
||||
id: Date.now().toString(),
|
||||
createdAt: new Date(),
|
||||
...toast,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
toasts.value.push({
|
||||
toast: toastWithId,
|
||||
options: { expire, once, timed },
|
||||
});
|
||||
|
||||
if (expire > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(toasts.value[0].id);
|
||||
removeToast(toastWithId.id);
|
||||
}, expire);
|
||||
}
|
||||
};
|
||||
|
||||
const removeToast = (id: Toast["id"]) => {
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id);
|
||||
toasts.value = toasts.value.filter((instance) => instance.toast.id !== id);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="128" height="128" rx="6" fill="#222222"/>
|
||||
<path d="M82.3248 94.3863H123V104.093H67.8025V95.3506L106.164 44.3736H68.3808V34.5382H121.072V42.9594L82.3248 94.3863Z" fill="#FFDD57"/>
|
||||
<path d="M8 107.107L17.5656 14L43.8372 16.7013C51.9339 17.5338 58.9091 20.0604 64.7629 24.2812C70.6166 28.5019 74.8873 34.0893 77.5749 41.0432C80.3052 48.0016 81.2514 55.7674 80.4137 64.3407L79.8027 70.2877C78.9005 79.0698 76.4053 86.5894 72.3173 92.8468C68.2719 99.1084 62.914 103.684 56.2436 106.574C49.6158 109.468 42.1213 110.529 33.7602 109.755L8 107.107ZM28.8005 25.3655L21.3043 98.3288L34.2164 99.6565C43.6767 100.629 51.3299 98.4435 57.1758 93.0993C63.0644 87.7595 66.5671 79.6542 67.684 68.7832L68.2424 63.3477C69.3286 52.7752 67.6788 44.3123 63.293 37.9592C58.9542 31.5678 52.2295 27.8607 43.1188 26.8377L28.8005 25.3655Z" fill="#FFDD57"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 949 B |
+28
-45
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<mobile-menu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></mobile-menu>
|
||||
<splitpanes @resized="onResized($event)">
|
||||
<pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
|
||||
<side-panel @search="showFuzzySearch"></side-panel>
|
||||
</pane>
|
||||
<pane min-size="10">
|
||||
<splitpanes>
|
||||
<pane class="router-view min-h-screen">
|
||||
<Splitpanes @resized="onResized($event)">
|
||||
<Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
|
||||
<SidePanel @search="showFuzzySearch" />
|
||||
</Pane>
|
||||
<Pane min-size="10" :size="100 - menuWidth">
|
||||
<Splitpanes>
|
||||
<Pane class="router-view min-h-screen">
|
||||
<router-view></router-view>
|
||||
</pane>
|
||||
</Pane>
|
||||
<template v-if="!isMobile">
|
||||
<pane v-for="other in pinnedLogs" :key="other.id">
|
||||
<Pane v-for="other in pinnedLogs" :key="other.id">
|
||||
<ContainerLog
|
||||
:id="other.id"
|
||||
show-title
|
||||
@@ -19,14 +19,14 @@
|
||||
closable
|
||||
@close="pinnedLogsStore.unPinContainer(other)"
|
||||
/>
|
||||
</pane>
|
||||
</Pane>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</pane>
|
||||
</splitpanes>
|
||||
</Splitpanes>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<label
|
||||
class="btn btn-circle swap btn-neutral swap-rotate fixed -left-12 bottom-4 w-16 transition-all hover:-left-4"
|
||||
:class="{ '!-left-6': collapseNav }"
|
||||
class="btn btn-circle swap bg-base-100 swap-rotate fixed bottom-4 -left-12 w-16 transition-all hover:-left-4"
|
||||
:class="{ '-left-6!': collapseNav }"
|
||||
v-if="!isMobile && !forceMenuHidden"
|
||||
>
|
||||
<input type="checkbox" v-model="collapseNav" />
|
||||
@@ -34,7 +34,11 @@
|
||||
<mdi:chevron-left class="swap-off" />
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal items-start bg-white/20 backdrop:backdrop-blur-sm" @close="open = false">
|
||||
<dialog
|
||||
ref="modal"
|
||||
class="modal items-start bg-white/20 transition-none backdrop:backdrop-blur-xs"
|
||||
@close="open = false"
|
||||
>
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="open = false" v-if="open" />
|
||||
</div>
|
||||
@@ -48,29 +52,7 @@
|
||||
<template #fallback> Loading dependencies... </template>
|
||||
</Suspense>
|
||||
</SideDrawer>
|
||||
<div class="toast toast-end whitespace-normal">
|
||||
<div
|
||||
class="alert max-w-xl"
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="{
|
||||
'alert-error': toast.type === 'error',
|
||||
'alert-info': toast.type === 'info',
|
||||
'alert-warning': toast.type === 'warning',
|
||||
}"
|
||||
>
|
||||
<carbon:information class="size-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
|
||||
<div v-html="toast.message" class="[&>a]:underline"></div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-circle btn-xs" @click="removeToast(toast.id)"><mdi:close /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToastModal />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -82,7 +64,6 @@ import SideDrawer from "@/components/common/SideDrawer.vue";
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
|
||||
const { toasts, removeToast } = useToast();
|
||||
const drawer = useTemplateRef<InstanceType<typeof SideDrawer>>("drawer") as Ref<InstanceType<typeof SideDrawer>>;
|
||||
const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer);
|
||||
|
||||
@@ -110,16 +91,18 @@ function showFuzzySearch() {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function onResized(e: any) {
|
||||
if (e.length == 2) {
|
||||
menuWidth.value = e[0].size;
|
||||
function onResized({ panes }: { panes: { size: number }[] }) {
|
||||
if (panes.length == 2) {
|
||||
menuWidth.value = Math.min(panes[0].size, 50);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
@apply min-w-[3px] bg-base-lighter hover:bg-secondary;
|
||||
@apply bg-base-100 hover:bg-secondary min-w-[5px];
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 396 114"><path class="secondary-fill" d="M89.408 46.477a6.49 6.49 0 0 0-6.488 6.49 6.488 6.488 0 1 0 12.978 0 6.49 6.49 0 0 0-6.49-6.49m-42.836 0a6.49 6.49 0 1 0 0 12.98 6.49 6.49 0 0 0 0-12.98M28.5 38.295a6.5 6.5 0 0 1-1.897-4.593 6.474 6.474 0 0 1 6.49-6.49c1.663 0 3.327.631 4.592 1.896l-1.517 1.12a51.67 51.67 0 0 1 31.822-10.91c11.999 0 23.028 4.078 31.823 10.91L98.3 29.113a6.47 6.47 0 0 1 4.592-1.897c1.659 0 3.324.63 4.588 1.897a6.52 6.52 0 0 1 1.892 4.593 6.47 6.47 0 0 1-1.892 4.594l1.514 1.113c21.944 21.942-15.406 31.822-15.406 31.822H42.392c-1.077 0-38.88-8.35-15.406-31.822zM102.214 94.411a7.894 7.894 0 0 1-7.894-7.895 7.893 7.893 0 1 1 15.787 0 7.89 7.89 0 0 1-7.893 7.895M37.355 95.13a7.894 7.894 0 0 1-7.895-7.896 7.894 7.894 0 0 1 7.895-7.893 7.894 7.894 0 0 1 7.896 7.893 7.894 7.894 0 0 1-7.896 7.895M44.372 72.866h47.402s-22.981 7.899-47.402 0"/><path class="content-fill" d="M142 79.928V33.446h13.836c3.571 0 6.886.574 9.947 1.786 3.06 1.147 5.675 2.805 7.779 4.846 4.272 4.144 6.822 10.01 6.822 16.514 0 3.379-.637 6.567-1.913 9.5-1.275 2.933-3.06 5.356-5.228 7.396-4.4 4.017-10.648 6.44-17.407 6.44zm4.91-41.89v37.3h8.735c3.889 0 7.332-.83 10.265-2.423 2.997-1.595 5.356-3.762 6.95-6.568 1.658-2.805 2.487-6.057 2.487-9.628 0-5.292-1.977-10.01-5.356-13.39-3.315-3.315-8.48-5.292-14.346-5.292zm86.587 18.554c0 4.4-1.02 8.416-2.997 12.05q-3.06 5.452-8.416 8.608c-3.634 2.104-7.588 3.188-12.051 3.188-3.252 0-6.312-.637-9.245-1.849-2.869-1.211-5.356-2.869-7.397-5.037-2.04-2.04-3.634-4.527-4.845-7.46-1.148-2.933-1.786-5.993-1.786-9.245q0-6.6 3.061-12.051c1.976-3.635 4.782-6.568 8.416-8.672C201.808 34.02 205.825 33 210.224 33c3.252 0 6.313.574 9.246 1.785 2.869 1.276 5.356 2.933 7.396 4.974 2.04 2.168 3.698 4.654 4.846 7.523 1.211 2.934 1.785 5.994 1.785 9.31m-5.037.255c0-5.229-1.913-10.075-5.101-13.581s-7.97-5.739-13.326-5.739c-3.443 0-6.567.893-9.373 2.55-2.805 1.786-4.973 4.081-6.503 6.95q-2.296 4.4-2.296 9.565c0 5.164 1.913 10.074 5.101 13.517 3.188 3.507 7.97 5.739 13.262 5.739 3.444 0 6.568-.893 9.373-2.615 2.87-1.657 5.037-4.017 6.568-6.886 1.53-2.87 2.295-6.057 2.295-9.5m43.932 18.618v4.463h-33.539v-4.463l27.672-37.555H239.81v-4.464h32.454v4.464l-27.8 37.555zm38.192 0v4.463h-33.538v-4.463l27.672-37.555h-26.715v-4.464h32.454v4.464l-27.8 37.555zm8.927 4.463V33.446h4.91v41.891h22.061v4.591zm34.622 0V33.446h29.075v4.527h-24.165v16.068h22.125v4.527h-22.125v16.833h24.42v4.527z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 97 76" fill="none"><path class="secondary-fill" d="M70.091 27.16a6.49 6.49 0 0 0-6.488 6.49 6.488 6.488 0 1 0 12.978 0 6.49 6.49 0 0 0-6.49-6.49m-42.836 0a6.49 6.49 0 1 0 0 12.98 6.49 6.49 0 0 0 0-12.98M9.182 18.977a6.5 6.5 0 0 1-1.898-4.592 6.474 6.474 0 0 1 6.49-6.49c1.664 0 3.327.631 4.593 1.896l-1.517 1.12A51.67 51.67 0 0 1 48.672 0C60.671 0 71.7 4.08 80.495 10.911l-1.512-1.115A6.46 6.46 0 0 1 83.575 7.9c1.659 0 3.324.63 4.587 1.897a6.51 6.51 0 0 1 1.893 4.593 6.47 6.47 0 0 1-1.893 4.594l1.515 1.113c21.944 21.942-15.406 31.822-15.406 31.822H23.075c-1.077 0-38.88-8.35-15.406-31.822zM82.897 75.094a7.894 7.894 0 0 1-7.894-7.895 7.893 7.893 0 1 1 15.787 0 7.893 7.893 0 0 1-7.893 7.895M18.038 75.812a7.894 7.894 0 0 1-7.895-7.895 7.894 7.894 0 0 1 7.895-7.894 7.894 7.894 0 0 1 7.896 7.894 7.894 7.894 0 0 1-7.896 7.895M25.055 53.549h47.402s-22.981 7.899-47.402 0"/></svg>
|
||||
|
After Width: | Height: | Size: 934 B |
+126
-97
@@ -1,110 +1,139 @@
|
||||
@import "splitpanes/dist/splitpanes.css";
|
||||
@import "tailwindcss";
|
||||
@import "splitpanes/dist/splitpanes.css" layer(base);
|
||||
@plugin "daisyui";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@theme {
|
||||
--color-green: oklch(69% 0.119722 188.479048);
|
||||
--color-red: oklch(64% 0.218 28.85);
|
||||
--color-purple: oklch(51.49% 0.215 321.03);
|
||||
--color-blue: oklch(65% 0.171 249.5);
|
||||
--color-orange: oklch(70% 0.186 48.13);
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: dark;
|
||||
|
||||
--color-base-100: oklch(25% 0 0);
|
||||
--color-base-200: oklch(18% 0 0);
|
||||
--color-base-300: oklch(11% 0 0);
|
||||
--color-base-content: oklch(89.23% 0 0);
|
||||
--color-primary: oklch(70.96% 0.143 176.65);
|
||||
--color-primary-content: oklch(98% 0.01 240);
|
||||
--color-secondary: oklch(81.38% 0.1448 90.1243);
|
||||
--color-secondary-content: oklch(98% 0.01 200);
|
||||
|
||||
--color-neutral: oklch(50% 0.05 240);
|
||||
--color-neutral-content: oklch(98% 0.01 240);
|
||||
|
||||
--color-info: var(--color-blue);
|
||||
--color-info-content: oklch(98% 0.01 220);
|
||||
|
||||
--color-success: var(--color-green);
|
||||
--color-success-content: oklch(98% 0.01 140);
|
||||
|
||||
--color-warning: var(--color-orange);
|
||||
--color-success-content: oklch(98% 0.01 140);
|
||||
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
--border: 1px;
|
||||
|
||||
--depth: 0;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: light;
|
||||
|
||||
--color-base-100: oklch(100% 0 0);
|
||||
--color-base-200: oklch(97% 0 0);
|
||||
--color-base-300: oklch(90% 0 0);
|
||||
--color-base-content: oklch(33% 0 0);
|
||||
--color-primary: oklch(79.96% 0.143 176.65);
|
||||
--color-primary-content: oklch(98% 0.01 240);
|
||||
--color-secondary: oklch(82.67% 0.15380739746702807 77.82);
|
||||
--color-secondary-content: oklch(98% 0.01 200);
|
||||
|
||||
--color-neutral: oklch(50% 0.05 240);
|
||||
--color-neutral-content: oklch(98% 0.01 240);
|
||||
|
||||
--color-info: var(--color-blue);
|
||||
--color-info-content: oklch(98% 0.01 220);
|
||||
|
||||
--color-success: var(--color-green);
|
||||
--color-success-content: oklch(98% 0.01 140);
|
||||
|
||||
--color-warning: var(--color-orange);
|
||||
--color-success-content: oklch(98% 0.01 140);
|
||||
|
||||
--color-error: var(--color-red);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
--border: 1px;
|
||||
|
||||
--depth: 0;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
@utility input {
|
||||
@apply outline-hidden!;
|
||||
&.input-ghost {
|
||||
@apply focus:border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@utility btn {
|
||||
&:not(.btn-primary):not(.btn-secondary) {
|
||||
@apply hover:bg-base-100;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-active {
|
||||
&,
|
||||
&:active {
|
||||
--menu-active-bg: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-bounce-fast {
|
||||
animation: bounce 0.5s 2 both;
|
||||
}
|
||||
|
||||
@utility link-primary {
|
||||
@apply underline-offset-4 hover:underline;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--p: var(--primary-color);
|
||||
--pc: 89.23% 0;
|
||||
--s: var(--secondary-color);
|
||||
--sc: 14.57% 0 0;
|
||||
--b1: var(--base-lighter-color);
|
||||
--b2: var(--base-color);
|
||||
--b3: var(--base-darker-color);
|
||||
--bc: var(--base-content-color);
|
||||
--in: 65% 0.171 249.5;
|
||||
--inc: 100% 0 0;
|
||||
--er: 64% 0.218 28.85;
|
||||
--su: 56% 0.119722 164.12;
|
||||
--erc: 100% 0 0;
|
||||
--wa: 70% 0.186 48.13;
|
||||
--wac: 100% 0 0;
|
||||
--n: var(--base-lighter-color);
|
||||
}
|
||||
html[data-theme="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"] {
|
||||
--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%;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.events mark {
|
||||
@apply inline-block rounded-sm bg-secondary;
|
||||
animation: pops 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
0% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input {
|
||||
@apply !outline-none;
|
||||
&.input-ghost {
|
||||
@apply focus:border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply font-normal normal-case;
|
||||
}
|
||||
|
||||
.btn:not(.btn-primary):not(.btn-secondary) {
|
||||
@apply hover:bg-base-lighter;
|
||||
}
|
||||
nav {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.active-primary {
|
||||
--n: var(--p);
|
||||
--nc: var(--pc);
|
||||
@apply active;
|
||||
}
|
||||
|
||||
.link-primary {
|
||||
@apply underline-offset-4 hover:underline;
|
||||
}
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,4 @@ const app = createApp(App);
|
||||
Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.ts", { eager: true })).forEach((i) =>
|
||||
i.install?.(app),
|
||||
);
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
@@ -23,13 +23,15 @@ export class GroupedContainers {
|
||||
|
||||
export class Container {
|
||||
private _stat: Ref<Stat>;
|
||||
private _name: string;
|
||||
private readonly _statsHistory: Ref<Stat[]>;
|
||||
private readonly movingAverageStat: Ref<Stat>;
|
||||
private readonly _name: string;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly created: Date,
|
||||
public startedAt: Date,
|
||||
public finishedAt: Date,
|
||||
public readonly image: string,
|
||||
name: string,
|
||||
public readonly command: string,
|
||||
@@ -76,6 +78,10 @@ export class Container {
|
||||
return this.group;
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.isSwarm
|
||||
? this.labels["com.docker.swarm.task.name"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON, getDeep } from "@/utils";
|
||||
import { flattenJSON } from "@/utils";
|
||||
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
|
||||
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
|
||||
import ContainerEventLogItem from "@/components/LogViewer/ContainerEventLogItem.vue";
|
||||
@@ -9,7 +9,17 @@ export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue
|
||||
export type JSONObject = { [x: string]: JSONValue };
|
||||
export type Position = "start" | "end" | "middle" | undefined;
|
||||
export type Std = "stdout" | "stderr";
|
||||
export type Level = "debug" | "info" | "warn" | "error" | "fatal" | "trace" | "unknown";
|
||||
export type Level =
|
||||
| "error"
|
||||
| "warn"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "trace"
|
||||
| "severe"
|
||||
| "critical"
|
||||
| "fatal"
|
||||
| "unknown";
|
||||
export interface LogEvent {
|
||||
readonly m: string | JSONObject;
|
||||
readonly ts: number;
|
||||
@@ -75,10 +85,18 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
if (visibleKeys.value.size === 0) {
|
||||
return flattenJSON(message);
|
||||
} else {
|
||||
const keys = Array.from(visibleKeys.value.entries())
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key);
|
||||
return keys.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
|
||||
const flatJSON = flattenJSON(message);
|
||||
const filteredJSON: Record<string, any> = {};
|
||||
for (const [keys, enabled] of visibleKeys.value.entries()) {
|
||||
const key = keys.join(".");
|
||||
if (!enabled) {
|
||||
delete flatJSON[key];
|
||||
continue;
|
||||
}
|
||||
filteredJSON[key] = flatJSON[key];
|
||||
delete flatJSON[key];
|
||||
}
|
||||
return { ...filteredJSON, ...flatJSON };
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -117,7 +135,7 @@ export class ContainerEventLogEntry extends LogEntry<string> {
|
||||
date: Date,
|
||||
public readonly event: "container-stopped" | "container-started",
|
||||
) {
|
||||
super(message, containerID, date.getTime(), date, "stderr", "info");
|
||||
super(message, containerID, date.getTime(), date, "stderr", "unknown");
|
||||
}
|
||||
getComponent(): Component {
|
||||
return ContainerEventLogItem;
|
||||
|
||||
@@ -42,7 +42,9 @@ await loadLanguage("en", false); // load default language
|
||||
const userLocale = computed(
|
||||
() =>
|
||||
locale.value ||
|
||||
[navigator.language, navigator.language.slice(0, 2)].find((l) => availableLocales.includes(l)) ||
|
||||
[navigator.language.toLowerCase(), navigator.language.toLowerCase().slice(0, 2)].find((l) =>
|
||||
availableLocales.includes(l),
|
||||
) ||
|
||||
"en",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<PageWithLinks>
|
||||
<div class="hero min-h-screen bg-base-200">
|
||||
<div class="hero bg-base-200 min-h-screen">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<p class="py-6 text-2xl font-bold">{{ $t("error.page-not-found") }}</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<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 v-else-if="ready" class="hero bg-base-200 min-h-screen">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<p class="py-6 text-2xl font-bold">{{ $t("error.container-not-found") }}</p>
|
||||
@@ -11,15 +11,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type Container } from "@/models/Container";
|
||||
const route = useRoute("/container/[id]");
|
||||
const id = toRef(() => route.params.id);
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const currentContainer = containerStore.currentContainer(id);
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
const { containers: allContainers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
|
||||
const { showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
@@ -30,8 +33,50 @@ watchEffect(() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const redirectTrigger = ref(false);
|
||||
watch(currentContainer, () => (redirectTrigger.value = false));
|
||||
|
||||
watchEffect(() => {
|
||||
if (redirectTrigger.value) return;
|
||||
if (!currentContainer.value) return;
|
||||
if (currentContainer.value.state === "running") return;
|
||||
if (Date.now() - +currentContainer.value.finishedAt > 5 * 60 * 1000) return;
|
||||
|
||||
const nextContainer = allContainers.value
|
||||
.filter((c) => c.startedAt > currentContainer.value.startedAt && c.name === currentContainer.value.name)
|
||||
.sort((a, b) => +a.created - +b.created)[0];
|
||||
|
||||
if (!nextContainer) return;
|
||||
|
||||
if (automaticRedirect.value) {
|
||||
redirectTrigger.value = true;
|
||||
showToast(
|
||||
{
|
||||
title: t("alert.similar-container-found.title"),
|
||||
message: t("alert.similar-container-found.message", { containerId: nextContainer.id }),
|
||||
type: "info",
|
||||
action: {
|
||||
label: t("button.cancel"),
|
||||
handler: () => {
|
||||
showToast(
|
||||
{
|
||||
title: t("alert.redirected.title"),
|
||||
message: t("alert.redirected.message", { containerId: nextContainer.id }),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 5000 },
|
||||
);
|
||||
router.push({ name: "/container/[id]", params: { id: nextContainer.id } });
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timed: 4000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
containerMode: true
|
||||
menu: host
|
||||
</route>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user