mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 20:51:56 +00:00
Compare commits
396 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6e5a1112 | |||
| 0bb47d4dcf | |||
| ae7eee9bca | |||
| d98b84e24a | |||
| 18c87256cc | |||
| e96631930e | |||
| 76b1229341 | |||
| 440f9394d6 | |||
| ec792318e6 | |||
| 16db90b660 | |||
| eb22752b9a | |||
| 642ab45ef7 | |||
| 12fadb9348 | |||
| 1cf178e37d | |||
| 061cd0c445 | |||
| e8f796a7d6 | |||
| 78f94f1f16 | |||
| cc980361fd | |||
| 736ad8ed5b | |||
| d0dbd2051e | |||
| 40a17b4088 | |||
| b3da3d70a9 | |||
| 28588fdfd2 | |||
| a33f7bf3b2 | |||
| febdbff0de | |||
| 0fee11e036 | |||
| b768cbfc89 | |||
| d43de49b6d | |||
| facc137a93 | |||
| a052f55dc6 | |||
| 481f2bd592 | |||
| b33ae4fa4e | |||
| f35c472088 | |||
| 7455a4c53c | |||
| 84101620f8 | |||
| 51695b486e | |||
| 2f84190faa | |||
| ebcedb2a67 | |||
| a514168e45 | |||
| 6bb3777a64 | |||
| d22e6f9014 | |||
| b779998476 | |||
| 70c23b0b1e | |||
| ddff265023 | |||
| 52edcce8d8 | |||
| 0f75010b35 | |||
| 5e228756f3 | |||
| 3775170457 | |||
| 42edc4c623 | |||
| f363b6f366 | |||
| df7e461876 | |||
| f691a22d05 | |||
| 9c2e03727b | |||
| 2177320259 | |||
| 2a2f6bc9c6 | |||
| e4fab590b9 | |||
| cf750b6e39 | |||
| 8e2ae35ad2 | |||
| 8b4f5ea32d | |||
| 2fc848254d | |||
| fa2dc863ea | |||
| 11a78629ef | |||
| 8b058cae07 | |||
| 8c4fcc6a7a | |||
| 9b5aaad109 | |||
| aad9287b97 | |||
| 1e6b7da245 | |||
| b8bc681f7a | |||
| c646281e46 | |||
| 27478eb652 | |||
| 492c5bfd32 | |||
| 397f42a223 | |||
| ddcc5ccbe1 | |||
| 9ae7c4cbd8 | |||
| 076f5d0fc9 | |||
| b8a8781a9b | |||
| d5abf1868a | |||
| d5a2841e02 | |||
| 410f5f305c | |||
| 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 |
@@ -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 = true
|
||||
|
||||
[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,6 +23,7 @@ body:
|
||||
- Agents
|
||||
- Remote Hosts
|
||||
- Swarm Mode
|
||||
- Kubernetes
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -47,17 +48,18 @@ body:
|
||||
id: environment
|
||||
attributes:
|
||||
label: 💻 Environment
|
||||
description: Run `docker system info` and paste the output in the field below..
|
||||
description: Run `docker system info` and paste the output in the field below.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 📜 Dozzle logs are required for debugging purposes. You may need to enable debug mode. See https://dozzle.dev/guide/debugging.
|
||||
description: Drag log files or paste the logs into the text input below
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 📸 If applicable, add screenshots to help explain your bug
|
||||
description: Drag issues into the text input below
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 📜 If applicable, attach your Dozzle logs. You many need to enable debug mode. See https://dozzle.dev/guide/debugging.
|
||||
description: Drag screenshots into the text input below
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: amirraminfar
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump"
|
||||
"rangeStrategy": "bump",
|
||||
"ignoreDeps": []
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22.x
|
||||
go-version: 1.24.2
|
||||
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,31 +90,46 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.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.9.0
|
||||
uses: docker/build-push-action@v6.15.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
build-args: |
|
||||
TAG=${{ steps.meta.outputs.version }}
|
||||
SHA=${{ github.sha.substr(0,9) }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -13,31 +13,41 @@ 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@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.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.9.0
|
||||
uses: docker/build-push-action@v6.15.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
build-args: |
|
||||
TAG=${{ steps.meta.outputs.version }}
|
||||
SHA=${{ github.sha.substr(0,9) }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24.2"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24.2"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
@@ -109,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
-3
@@ -1,5 +1,5 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:23.1.0-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:23.11.0-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY public ./public
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23.3-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\
|
||||
@@ -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 ./
|
||||
@@ -47,19 +48,21 @@ COPY --from=node /build/dist ./dist
|
||||
|
||||
# Args
|
||||
ARG TAG=dev
|
||||
ARG SHA=
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Generate protos
|
||||
RUN go generate
|
||||
|
||||
# Build binary
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG -X github.com/amir20/dozzle/internal/support/cli.SHA=$SHA" -o dozzle
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /data /data
|
||||
COPY --from=builder /tmp /tmp
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=builder /dozzle/dozzle /dozzle
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ PROTO_DIR := protos
|
||||
GEN_DIR := internal/agent/pb
|
||||
PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto)
|
||||
GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES))
|
||||
|
||||
SHA := $(shell git rev-parse --short HEAD)
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@rm -rf dist
|
||||
@@ -26,11 +26,12 @@ test: fake_assets generate
|
||||
|
||||
.PHONY: build
|
||||
build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
echo "Building with SHA: $(SHA)"
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local -X github.com/amir20/dozzle/internal/support/cli.SHA=$(SHA)"
|
||||
|
||||
.PHONY: docker
|
||||
docker: shared_key.pem shared_cert.pem
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle .
|
||||
@docker build --build-arg TAG=local --build-arg SHA=$(SHA) -t amir20/dozzle .
|
||||
|
||||
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
|
||||
|
||||
@@ -43,7 +44,7 @@ int:
|
||||
docker compose up --build --force-recreate --exit-code-from playwright
|
||||
|
||||
shared_key.pem:
|
||||
@openssl genpkey -algorithm RSA -out shared_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
@openssl genpkey -algorithm Ed25519 -out shared_key.pem
|
||||
|
||||
shared_cert.pem: shared_key.pem
|
||||
@openssl req -new -key shared_key.pem -out shared_request.csr -subj "/C=US/ST=California/L=San Francisco/O=Dozzle"
|
||||
@@ -61,7 +62,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
|
||||
|
||||
@@ -94,12 +94,12 @@ 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.
|
||||
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```.
|
||||
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)
|
||||
|
||||
@@ -115,24 +115,7 @@ If you do not want to be tracked at all, see the `--no-analytics` flag below.
|
||||
|
||||
## Environment variables and configuration
|
||||
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. The table below outlines all supported options and their respective env vars.
|
||||
|
||||
| Flag | Env Variable | Default |
|
||||
| --------------------- | -------------------------- | -------------- |
|
||||
| `--addr` | `DOZZLE_ADDR` | `:8080` |
|
||||
| `--base` | `DOZZLE_BASE` | `/` |
|
||||
| `--hostname` | `DOZZLE_HOSTNAME` | `""` |
|
||||
| `--level` | `DOZZLE_LEVEL` | `info` |
|
||||
| `--auth-provider` | `DOZZLE_AUTH_PROVIDER` | `none` |
|
||||
| `--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 |
|
||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
|
||||
| `--mode` | `DOZZLE_MODE` | `server` |
|
||||
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
|
||||
| `--remote-agent` | `DOZZLE_REMOTE_AGENT` | |
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. See documentation at [https://dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
|
||||
|
||||
## 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" : "#F5F5F5");
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
});
|
||||
</script>
|
||||
<style lang="postcss">
|
||||
<style>
|
||||
html.has-custom-scrollbars {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
Vendored
+30
-3
@@ -31,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']
|
||||
@@ -87,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']
|
||||
@@ -161,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']
|
||||
@@ -191,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']
|
||||
@@ -234,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']
|
||||
@@ -280,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']
|
||||
@@ -367,8 +373,23 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, Slot, Slots, 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
|
||||
@@ -401,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']>
|
||||
@@ -457,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']>
|
||||
@@ -531,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']>
|
||||
@@ -561,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']>
|
||||
@@ -604,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']>
|
||||
@@ -650,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']>
|
||||
|
||||
Vendored
+12
-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,8 +42,10 @@ 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']
|
||||
@@ -56,6 +62,7 @@ declare module 'vue' {
|
||||
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']
|
||||
@@ -89,6 +96,7 @@ 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']
|
||||
@@ -96,7 +104,7 @@ declare module 'vue' {
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||
Releases: typeof import('./components/Releases.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']
|
||||
@@ -115,7 +123,9 @@ declare module 'vue' {
|
||||
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"
|
||||
@@ -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 w-10 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") }}
|
||||
@@ -31,8 +31,8 @@
|
||||
<details>
|
||||
<summary>
|
||||
<div class="flex w-4">
|
||||
<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" />
|
||||
</div>
|
||||
Streams
|
||||
</summary>
|
||||
@@ -134,6 +134,22 @@
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template v-if="enableShell">
|
||||
<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>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -142,9 +158,10 @@
|
||||
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 { enableActions, enableShell } = config;
|
||||
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
|
||||
const showDrawer = useDrawer();
|
||||
|
||||
@@ -160,6 +177,21 @@ onKeyStroke("f", (e) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
if (enableShell) {
|
||||
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))
|
||||
@@ -169,7 +201,7 @@ 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()}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -187,9 +219,11 @@ const toggleAllLevels = computed({
|
||||
});
|
||||
</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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 font-mono">
|
||||
<ul>
|
||||
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
|
||||
{{ container.hostLabel }}
|
||||
</li>
|
||||
<li>
|
||||
<template v-if="otherContainers.length === 0">{{ container.name }}</template>
|
||||
<div class="wrapper" ref="wrapper" v-else>
|
||||
<button popovertarget="popover-container-list" class="btn btn-xs md:btn-sm anchor">
|
||||
{{ 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 v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div 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,51 @@ 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",
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
),
|
||||
new Container(
|
||||
"345",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"foo bar",
|
||||
"command",
|
||||
"host",
|
||||
{},
|
||||
"running",
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
),
|
||||
new Container(
|
||||
"567",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"baz",
|
||||
"command",
|
||||
"host",
|
||||
{},
|
||||
"running",
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-open w-full">
|
||||
<div class="input input-primary flex h-auto items-center">
|
||||
<div class="dropdown dropdown-open w-full shadow-md">
|
||||
<div class="input input-xl input-primary flex w-full items-center">
|
||||
<mdi:magnify class="flex size-8" />
|
||||
<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 items-center 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>
|
||||
|
||||
@@ -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,18 +40,18 @@
|
||||
|
||||
<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) }}
|
||||
{{ formatBytes(weightedStats[host.id].weighted.totalMem, { decimals: 1, short: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')"
|
||||
@@ -66,7 +100,7 @@
|
||||
</span>
|
||||
</router-link>
|
||||
<template #content>
|
||||
<ContainerPopup :container="item as Container" />
|
||||
<ContainerPopup :container="item" />
|
||||
</template>
|
||||
</Popup>
|
||||
</li>
|
||||
@@ -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,7 +1,7 @@
|
||||
<template>
|
||||
<LogItem :logEntry @click="showDrawer(LogDetails, { entry: logEntry })" class="clickable">
|
||||
<ul class="fields space-x-4">
|
||||
<li v-for="(value, name) in validValues" :key="name">
|
||||
<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>
|
||||
@@ -29,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;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,21 @@ 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",
|
||||
0,
|
||||
0,
|
||||
[],
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
|
||||
<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 shuffle(['w-3/5', 'w-2/3', 'w-9/12', 'w-1/2', 'w-1/3', 'w-3/4'])">
|
||||
<div class="h-3 w-40 shrink-0 rounded-full bg-base-content/50 opacity-50"></div>
|
||||
<div class="h-3 rounded-full bg-base-content/50 opacity-50" :class="size"></div>
|
||||
<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>
|
||||
@@ -20,7 +20,7 @@ const { entity, streamSource } = $defineProps<{
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
const { messages, loadOlderLogs, isLoadingMore, opened, loading, error } = streamSource($$(entity));
|
||||
const { messages, loadOlderLogs, isLoadingMore, opened, loading, error, eventSourceURL } = streamSource($$(entity));
|
||||
const { loadingMore } = useLoggingContext();
|
||||
const color = computed(() => {
|
||||
if (error.value) return "error";
|
||||
@@ -48,4 +48,8 @@ const fetchMore = async () => {
|
||||
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>
|
||||
|
||||
@@ -11,18 +11,26 @@
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="textarea textarea-primary w-full font-mono text-lg"
|
||||
:class="{ 'textarea-error': error }"
|
||||
:class="{ 'textarea-error!': error }"
|
||||
:disabled="state === 'downloading'"
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<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
|
||||
>
|
||||
<div class="mt-2">
|
||||
<span class="text-error" v-if="error">{{ error }}</span>
|
||||
<span v-else-if="state === 'initializing'">{{ $t("analytics.creating_table") }}</span>
|
||||
<span v-else-if="state === 'downloading'">{{
|
||||
$t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) })
|
||||
}}</span>
|
||||
<span v-else-if="evaluating">{{ $t("analytics.evaluating_query") }}</span>
|
||||
<span v-else>
|
||||
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
|
||||
<template v-if="results.numRows > pageLimit">{{
|
||||
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
|
||||
}}</template>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<SQLTable :table="page" :loading="evaluating || !isReady" />
|
||||
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -30,12 +38,15 @@
|
||||
<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 LIMIT 100");
|
||||
const error = ref<string | null>(null);
|
||||
const debouncedQuery = debouncedRef(query, 500);
|
||||
const evaluating = ref(false);
|
||||
const pageLimit = 1000;
|
||||
const state = ref<"downloading" | "ready" | "initializing">("downloading");
|
||||
const bytes = ref(0);
|
||||
|
||||
const url = withBase(
|
||||
`/api/hosts/${container.host}/containers/${container.id}/logs?stdout=1&stderr=1&everything&jsonOnly`,
|
||||
@@ -51,27 +62,49 @@ if (!response.ok) {
|
||||
const { db, conn } = await useDuckDB();
|
||||
const empty = await conn.query<Record<string, any>>(`SELECT 1 LIMIT 0`);
|
||||
|
||||
const { isReady } = useAsyncState(
|
||||
async () => {
|
||||
await db.registerFileBuffer("logs.json", new Uint8Array(await response.arrayBuffer()));
|
||||
onMounted(async () => {
|
||||
try {
|
||||
state.value = "downloading";
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No reader available from stream");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
bytes.value = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
bytes.value += value.length;
|
||||
}
|
||||
|
||||
const arrayBuffer = new Uint8Array(bytes.value);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
arrayBuffer.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
await db.registerFileBuffer("logs.json", arrayBuffer);
|
||||
|
||||
state.value = "initializing";
|
||||
await conn.query(
|
||||
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited')`,
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
error.value = e.message;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
state.value = "ready";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const results = computedAsync(
|
||||
async () => {
|
||||
if (isReady.value) {
|
||||
if (state.value === "ready") {
|
||||
return await conn.query<Record<string, any>>(debouncedQuery.value);
|
||||
} else {
|
||||
return empty;
|
||||
@@ -89,9 +122,12 @@ const results = computedAsync(
|
||||
},
|
||||
);
|
||||
|
||||
whenever(evaluating, () => (error.value = null));
|
||||
whenever(evaluating, () => {
|
||||
error.value = null;
|
||||
state.value = "ready";
|
||||
});
|
||||
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" class="!items-start">
|
||||
<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,28 +26,28 @@
|
||||
</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="max-md:hidden">Value</th>
|
||||
<th class="w-20">
|
||||
<input type="checkbox" class="toggle toggle-primary" v-model="toggleAllFields" title="Toggle all" />
|
||||
</th>
|
||||
@@ -55,10 +55,10 @@
|
||||
</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>
|
||||
@@ -72,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 }>();
|
||||
@@ -82,6 +81,8 @@ 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]));
|
||||
@@ -146,8 +147,8 @@ function syntaxHighlight(json: any) {
|
||||
/("(\\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";
|
||||
@@ -164,7 +165,8 @@ function syntaxHighlight(json: any) {
|
||||
|
||||
useSortable(list, fields);
|
||||
</script>
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.font-mono {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
@@ -178,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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" />
|
||||
<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"
|
||||
@@ -26,8 +26,3 @@ const { hosts } = useHosts();
|
||||
const container = currentContainer(toRef(() => logEntry.containerID));
|
||||
const host = computed(() => hosts.value[container.value.host]);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.log-wrapper :deep(a) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -53,7 +53,8 @@ useIntersectionObserver(
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.events {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
@@ -66,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,5 +94,19 @@ useIntersectionObserver(
|
||||
@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" class="!items-start">
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -39,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,7 +58,7 @@
|
||||
"
|
||||
>
|
||||
<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>
|
||||
@@ -84,12 +87,25 @@ const { showSearch } = useSearchFilter();
|
||||
|
||||
const clear = defineEmit();
|
||||
|
||||
const { streamConfig, showHostname, showContainerName } = 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 {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div class="flex gap-4">
|
||||
<StatMonitor :data="memoryData" label="mem" :stat-value="formatBytes(totalStat.memoryUsage)" />
|
||||
<StatMonitor :data="cpuData" label="load" :stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'" />
|
||||
<StatMonitor
|
||||
:data="memoryData"
|
||||
label="mem"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
|
||||
/>
|
||||
<StatMonitor
|
||||
:data="cpuData"
|
||||
label="load"
|
||||
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
|
||||
:limit="roundCPU(limits.cpu) + ' CPU'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +25,9 @@ const { containers } = defineProps<{
|
||||
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
|
||||
|
||||
watch(
|
||||
() => containers,
|
||||
@@ -42,6 +55,16 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const limits = computed(() =>
|
||||
containers.reduce(
|
||||
(acc, c) => ({
|
||||
cpu: acc.cpu + (c.cpuLimit > 0 ? c.cpuLimit : hosts.value[c.host].nCPU),
|
||||
memory: acc.memory + (c.memoryLimit > 0 ? c.memoryLimit : hosts.value[c.host].memTotal),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 },
|
||||
),
|
||||
);
|
||||
|
||||
useIntervalFn(() => {
|
||||
totalStat.value = containers.reduce(
|
||||
(acc, { stat }) => {
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
<template>
|
||||
<div class="tag grid w-40 overflow-hidden rounded text-center text-sm text-white">
|
||||
<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 [direction:rtl]">{{ value }}</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 = [
|
||||
"#4B0082",
|
||||
"#FF00FF",
|
||||
"#FF7F00",
|
||||
"#FFFF00",
|
||||
"#00FF00",
|
||||
"#00FFFF",
|
||||
"#FF0000",
|
||||
"#0000FF",
|
||||
"#FF007F",
|
||||
"#32CD32",
|
||||
"#40E0D0",
|
||||
"#E6E6FA",
|
||||
"#800080",
|
||||
"#FFD700",
|
||||
"#FF4040",
|
||||
"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 } = defineProps<{
|
||||
const { value, truncateRight = false } = defineProps<{
|
||||
value: string;
|
||||
truncateRight?: boolean;
|
||||
}>();
|
||||
|
||||
const color = computed(() => colors[Math.abs(hashCode(value)) % colors.length]);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
.random-color {
|
||||
background-color: v-bind(color);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<table class="table table-zebra table-pin-rows table-md" v-if="!loading">
|
||||
<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>
|
||||
@@ -11,18 +11,18 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-md animate-pulse" v-else>
|
||||
<table class="table-md table 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>
|
||||
<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="h-4 w-20 bg-base-content/50 opacity-20"></div>
|
||||
<div class="bg-base-content/50 h-4 w-20 opacity-20"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<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"
|
||||
/>
|
||||
@@ -19,8 +19,8 @@ import stripAnsi from "strip-ansi";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "oklch(var(--base-content-color))",
|
||||
bg: "oklch(var(--base-color))",
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
@@ -28,7 +28,15 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
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>
|
||||
@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,25 +1,31 @@
|
||||
<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 overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden">
|
||||
<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 }}
|
||||
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { data, label, statValue } = defineProps<{ data: Point<unknown>[]; label: string; statValue: string | number }>();
|
||||
|
||||
let selectedPoint: Point<unknown> | undefined = $ref();
|
||||
|
||||
function onSelectedPoint(point: Point<unknown>) {
|
||||
selectedPoint = point;
|
||||
}
|
||||
|
||||
let mouseOver = $ref(false);
|
||||
const {
|
||||
data,
|
||||
label,
|
||||
statValue,
|
||||
limit = -1,
|
||||
} = defineProps<{
|
||||
data: Point<unknown>[];
|
||||
label: string;
|
||||
statValue: string | number;
|
||||
limit?: string | number;
|
||||
}>();
|
||||
const selectedPoint = ref<Point<unknown> | undefined>();
|
||||
const onSelectedPoint = (point: Point<unknown>) => (selectedPoint.value = point);
|
||||
const mouseOver = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { scaleLinear } from "d3-scale";
|
||||
import { area, curveStep } from "d3-shape";
|
||||
|
||||
const d3 = { extent, scaleLinear, area, curveStep };
|
||||
const { data, width = 150, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
|
||||
const { data, width = 175, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
|
||||
const x = d3.scaleLinear().range([0, width]);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<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-1344ff3d="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<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-->
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" data-v-1344ff3d="" class="tag inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] !items-start shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
<div 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-1344ff3d="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div 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>
|
||||
@@ -23,16 +23,16 @@ 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" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-1344ff3d="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<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-->
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" data-v-1344ff3d="" class="tag inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] !items-start shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
|
||||
<div 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-1344ff3d="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div 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>
|
||||
@@ -43,16 +43,16 @@ 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" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-1344ff3d="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<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-->
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7="" data-v-1344ff3d="" class="tag inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] !items-start shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
<div 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-1344ff3d="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
|
||||
<div 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>
|
||||
|
||||
@@ -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 items-center 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>
|
||||
|
||||
@@ -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,19 +2,19 @@
|
||||
<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>
|
||||
@@ -28,7 +28,7 @@
|
||||
<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"
|
||||
@@ -77,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;
|
||||
|
||||
@@ -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 items-center 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>
|
||||
|
||||
@@ -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,8 +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: '/' }">
|
||||
<LogoWithText class="logo h-16 w-40" />
|
||||
<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">
|
||||
@@ -11,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"
|
||||
@@ -21,7 +21,7 @@
|
||||
<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>
|
||||
|
||||
@@ -30,14 +30,4 @@ import LogoWithText from "@/logo-text.svg";
|
||||
const { hostname } = config;
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.logo {
|
||||
:deep(.content-fill) {
|
||||
@apply fill-base-content;
|
||||
}
|
||||
|
||||
:deep(.secondary-fill) {
|
||||
@apply fill-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<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 items-center 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>
|
||||
|
||||
@@ -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,95 @@
|
||||
<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(`Attaching 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;
|
||||
}
|
||||
|
||||
& :deep(.xterm-cursor-block.xterm-cursor-blink) {
|
||||
animation-name: blink !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
background-color: var(--color-base-content);
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: inherit;
|
||||
color: var(--color-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,7 +3,9 @@
|
||||
<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="update(item.value as T)">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="animate-background h-1 w-1/2 bg-gradient-radial to-transparent to-75%" :class="colorClass"></div>
|
||||
<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" }>();
|
||||
@@ -16,7 +16,7 @@ const colorClass = computed(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style scoped>
|
||||
.animate-background {
|
||||
animation: gradient-animation 3s ease-out infinite;
|
||||
}
|
||||
|
||||
@@ -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,8 +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: '/' }">
|
||||
<Logo class="logo h-8" />
|
||||
<Logo class="logo [&_.secondary-fill]:fill-secondary h-8" />
|
||||
</router-link>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
@@ -18,8 +18,8 @@
|
||||
</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>
|
||||
@@ -34,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;
|
||||
}
|
||||
|
||||
@@ -54,14 +51,8 @@ li.exited {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.fade-enter-from .menu,
|
||||
.fade-leave-to .menu {
|
||||
@apply -translate-y-2;
|
||||
}
|
||||
|
||||
.logo {
|
||||
:deep(.secondary-fill) {
|
||||
@apply fill-secondary;
|
||||
}
|
||||
.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="tag 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:end-auto max-md:m-0 max-md:max-w-full">
|
||||
<div
|
||||
class="alert max-w-xl shadow-sm 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>
|
||||
@@ -10,7 +10,5 @@
|
||||
</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`));
|
||||
}
|
||||
@@ -130,16 +134,20 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -218,5 +226,14 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
}
|
||||
});
|
||||
|
||||
return { messages, loadOlderLogs, isLoadingMore, hasComplexLogs, opened, error, loading };
|
||||
return {
|
||||
messages,
|
||||
loadOlderLogs,
|
||||
isLoadingMore,
|
||||
hasComplexLogs,
|
||||
opened,
|
||||
error,
|
||||
loading,
|
||||
eventSourceURL: urlWithParams,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,17 +11,7 @@ type LogContext = {
|
||||
showHostname: boolean;
|
||||
};
|
||||
|
||||
export const allLevels: Level[] = [
|
||||
"info",
|
||||
"debug",
|
||||
"warn",
|
||||
"error",
|
||||
"fatal",
|
||||
"trace",
|
||||
"warning",
|
||||
"critical",
|
||||
"unknown",
|
||||
];
|
||||
export const allLevels: Level[] = ["info", "debug", "warn", "error", "fatal", "trace", "unknown"];
|
||||
|
||||
// export for testing
|
||||
export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
+24
-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-content/10 swap-rotate border-base-content/20 hover:border-primary fixed bottom-4 -left-12 w-16 shadow-sm transition-all hover:-left-4"
|
||||
:class="{ '-left-6!': collapseNav }"
|
||||
v-if="!isMobile && !forceMenuHidden"
|
||||
>
|
||||
<input type="checkbox" v-model="collapseNav" />
|
||||
@@ -34,7 +34,7 @@
|
||||
<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 bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="open = false">
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="open = false" v-if="open" />
|
||||
</div>
|
||||
@@ -48,29 +48,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 +60,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 +87,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) {
|
||||
|
||||
+124
-99
@@ -1,110 +1,135 @@
|
||||
@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: 81.38% 0.1448 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;
|
||||
}
|
||||
}
|
||||
|
||||
@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");
|
||||
|
||||
@@ -30,12 +30,16 @@ export class Container {
|
||||
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,
|
||||
public readonly host: string,
|
||||
public readonly labels = {} as Record<string, string>,
|
||||
public state: ContainerState,
|
||||
public readonly cpuLimit: number,
|
||||
public readonly memoryLimit: number,
|
||||
stats: Stat[],
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
|
||||
@@ -135,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>
|
||||
|
||||
@@ -22,5 +22,5 @@ watchEffect(() => {
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
swarmMode: true
|
||||
menu: group
|
||||
</route>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Search />
|
||||
<HostLog :id="route.params.id" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/host/[id]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
const { hosts } = useHosts();
|
||||
const host = computed(() => hosts.value[route.params.id]);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (host.value?.name) {
|
||||
setTitle(host.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
menu: host
|
||||
</route>
|
||||
+1
-17
@@ -29,25 +29,9 @@ watchEffect(() => {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
:deep(tr td) {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.stat > div {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply font-light;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
@apply font-light;
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
+47
-27
@@ -1,36 +1,53 @@
|
||||
<template>
|
||||
<div class="card w-96 flex-shrink-0 bg-base-lighter shadow-2xl">
|
||||
<div class="card bg-base-100 w-96 shrink-0 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<form action="" method="post" @submit.prevent="onLogin" ref="form" class="flex flex-col gap-8">
|
||||
<label class="input input-bordered flex items-center gap-2 border-2 has-[:focus]:input-primary">
|
||||
<mdi:account class="has-[+:focus]:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
:placeholder="$t('label.username')"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
<label class="form-control w-full">
|
||||
<label
|
||||
class="input floating-label input-bordered has-[:focus]:input-primary flex items-center gap-2 border-2"
|
||||
:class="{ 'input-error': error }"
|
||||
>
|
||||
<span class="ml-5">{{ $t("label.username") }}</span>
|
||||
<mdi:account class="has-[+:focus]:text-primary" :class="{ 'text-error': error }" />
|
||||
<input
|
||||
type="text"
|
||||
:class="{ 'text-error': error }"
|
||||
:placeholder="$t('label.username')"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
</label>
|
||||
<label class="label" v-if="error">
|
||||
<span class="label-text-alt text-error">
|
||||
{{ $t("error.invalid-auth") }}
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
<label class="input input-bordered flex items-center gap-2 border-2 has-[:focus]:input-primary">
|
||||
<mdi:key class="has-[+:focus]:text-primary" />
|
||||
<input
|
||||
type="password"
|
||||
class="grow"
|
||||
:placeholder="$t('label.password')"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="label text-red" v-if="error">
|
||||
{{ $t("error.invalid-auth") }}
|
||||
<label class="form-control w-full">
|
||||
<label
|
||||
class="input floating-label input-bordered has-[:focus]:input-primary flex items-center gap-2 border-2"
|
||||
>
|
||||
<span class="ml-5">{{ $t("label.password") }}</span>
|
||||
<mdi:key class="has-[+:focus]:text-primary" />
|
||||
<input
|
||||
type="password"
|
||||
:placeholder="$t('label.password')"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary uppercase" type="submit">{{ $t("button.login") }}</button>
|
||||
<button class="btn btn-primary uppercase" type="submit" :disabled="loading">
|
||||
<span class="loading loading-spinner" v-if="loading"></span>
|
||||
{{ $t("button.login") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,10 +59,12 @@ const { t } = useI18n();
|
||||
setTitle(t("title.login"));
|
||||
|
||||
const error = ref(false);
|
||||
const loading = ref(false);
|
||||
const form = ref<HTMLFormElement>();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
async function onLogin() {
|
||||
loading.value = true;
|
||||
const response = await fetch(withBase("/api/token"), {
|
||||
body: new FormData(form.value),
|
||||
method: "POST",
|
||||
@@ -61,6 +80,7 @@ async function onLogin() {
|
||||
} else {
|
||||
error.value = true;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user