Compare commits
463 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20425bf6b1 | ||
|
|
828c288570 | ||
|
|
cde2589755 | ||
|
|
5fb2f452e2 | ||
|
|
6aea252d3e | ||
|
|
7337dcb5d4 | ||
|
|
deeb5fc100 | ||
|
|
5b15a0b29d | ||
|
|
e2ad2e0193 | ||
|
|
5b48426fc1 | ||
|
|
a24947ab3b | ||
|
|
8926b451d0 | ||
|
|
afd37d3455 | ||
|
|
4f84beb835 | ||
|
|
d54d894a66 | ||
|
|
7c854d31a7 | ||
|
|
4c2caad4a0 | ||
|
|
9684fd978b | ||
|
|
a79a3f680f | ||
|
|
1e18423b04 | ||
|
|
82b208c0bf | ||
|
|
8a3e9504a4 | ||
|
|
d8748c6e27 | ||
|
|
af8192fbc0 | ||
|
|
3492587d63 | ||
|
|
b8c522021d | ||
|
|
5850c319f2 | ||
|
|
afbed43185 | ||
|
|
d083430c73 | ||
|
|
79f553ff0c | ||
|
|
a02551f5ec | ||
|
|
7c486d57fc | ||
|
|
41acf28be9 | ||
|
|
21e5f4fc56 | ||
|
|
2d54cbba9c | ||
|
|
88844c895c | ||
|
|
7aa7f42c52 | ||
|
|
59f4b0da4f | ||
|
|
e99e6ebd49 | ||
|
|
3268c32627 | ||
|
|
5cb5cae113 | ||
|
|
dbd1050948 | ||
|
|
69c647336e | ||
|
|
1a1dd74142 | ||
|
|
307dcd1929 | ||
|
|
06bde85e03 | ||
|
|
9cbb55d780 | ||
|
|
744bc11a2e | ||
|
|
4fe8964d66 | ||
|
|
3f13ef28a9 | ||
|
|
28c569ce2a | ||
|
|
c1dd3c1131 | ||
|
|
492706367b | ||
|
|
cbdbe65b7b | ||
|
|
de84736ba4 | ||
|
|
0043ed9291 | ||
|
|
89f7d21739 | ||
|
|
fb0b11e626 | ||
|
|
70d72060d9 | ||
|
|
cc8a7ee8e7 | ||
|
|
58197f2b23 | ||
|
|
d2473e0fcc | ||
|
|
3a4de053b8 | ||
|
|
b97dd31c9d | ||
|
|
a632c744bc | ||
|
|
7eeb7d8600 | ||
|
|
8de492d16b | ||
|
|
2b82a0816c | ||
|
|
25daf6b502 | ||
|
|
011cef8124 | ||
|
|
d06eea2c26 | ||
|
|
cdb6738941 | ||
|
|
f39b6f50e3 | ||
|
|
4d03a36940 | ||
|
|
60fc2ab22a | ||
|
|
0ad841a2d2 | ||
|
|
58ce210924 | ||
|
|
0214b212ea | ||
|
|
ee37d7c30e | ||
|
|
4395bc9dc5 | ||
|
|
4ea945f0b4 | ||
|
|
b221242db3 | ||
|
|
ad6793f614 | ||
|
|
72f080f795 | ||
|
|
ee4210e1cc | ||
|
|
fc68ba6391 | ||
|
|
539829d00d | ||
|
|
65c0d2a970 | ||
|
|
696341b779 | ||
|
|
658efd0538 | ||
|
|
eba4cec7d6 | ||
|
|
79f0e2127a | ||
|
|
163b1c7e28 | ||
|
|
db01579f04 | ||
|
|
be7c154d6b | ||
|
|
b1bc706de2 | ||
|
|
40f5cb1301 | ||
|
|
cedfbee983 | ||
|
|
c835f51cc4 | ||
|
|
5ab06d5906 | ||
|
|
d44316fa9c | ||
|
|
6ef3da9abd | ||
|
|
752495ed6f | ||
|
|
8f895e40bc | ||
|
|
cd9ddcf427 | ||
|
|
bbc7794006 | ||
|
|
7dc37f130c | ||
|
|
0711bc1c76 | ||
|
|
0aa24386b2 | ||
|
|
ca35b93671 | ||
|
|
a6220e4d38 | ||
|
|
4ed64a7cce | ||
|
|
0f27e11084 | ||
|
|
85eafc9c40 | ||
|
|
332cc384ea | ||
|
|
72fd31f85b | ||
|
|
a0ce370e9e | ||
|
|
e823904865 | ||
|
|
22bbfe1592 | ||
|
|
770e1818f0 | ||
|
|
d6fab75f8f | ||
|
|
17c18c156e | ||
|
|
5eca19840e | ||
|
|
b1d7b8ba55 | ||
|
|
e2ee430bbd | ||
|
|
0755a71dc2 | ||
|
|
60758db9c8 | ||
|
|
7b96196904 | ||
|
|
efcfa0e375 | ||
|
|
66f9204ae6 | ||
|
|
73c023ce22 | ||
|
|
261517ac3f | ||
|
|
2e0a546aa2 | ||
|
|
72ed7b50ba | ||
|
|
486bcec363 | ||
|
|
3db0ad42fe | ||
|
|
c1a75e21ba | ||
|
|
96c5e24501 | ||
|
|
c1a16fd76e | ||
|
|
42fab58c9f | ||
|
|
400cef767f | ||
|
|
84ae558467 | ||
|
|
0ebc9c562a | ||
|
|
f67664470f | ||
|
|
1f811da273 | ||
|
|
fdfc9fceba | ||
|
|
5b5b741b68 | ||
|
|
18c88d0e85 | ||
|
|
1603a19538 | ||
|
|
5cffa287d5 | ||
|
|
93f57b6e90 | ||
|
|
2346f6a0eb | ||
|
|
f95317ac1d | ||
|
|
157a612f34 | ||
|
|
42c890ad50 | ||
|
|
48638a18f2 | ||
|
|
fae0640bba | ||
|
|
23b37bb912 | ||
|
|
07135fea91 | ||
|
|
1a3c394fe4 | ||
|
|
38ec37ed19 | ||
|
|
738ae98f2f | ||
|
|
99d1e83882 | ||
|
|
d71be7e239 | ||
|
|
c0b9325efb | ||
|
|
538fe6f158 | ||
|
|
965d1a52b1 | ||
|
|
6be73692ba | ||
|
|
f694c168d3 | ||
|
|
b7c24dcafa | ||
|
|
67a1c4a207 | ||
|
|
b188f689ea | ||
|
|
6f354c500c | ||
|
|
8ba5d36801 | ||
|
|
6822a95cc9 | ||
|
|
fcc4647379 | ||
|
|
0305ee9502 | ||
|
|
0e527e8ec0 | ||
|
|
91b2dc36c2 | ||
|
|
3dc7949a86 | ||
|
|
5cf625ef65 | ||
|
|
8d5deff2ed | ||
|
|
9f6df9a25a | ||
|
|
a34733bc88 | ||
|
|
7cf02f40e6 | ||
|
|
9d2e87f0f3 | ||
|
|
034984a784 | ||
|
|
d11fcdfec5 | ||
|
|
af08b5cd1b | ||
|
|
c666917740 | ||
|
|
059c3361ca | ||
|
|
f18fdcec8c | ||
|
|
18d6aa2a34 | ||
|
|
db4643d271 | ||
|
|
9d5b6faf03 | ||
|
|
72e0a1ba2d | ||
|
|
35d4f3c8d3 | ||
|
|
6dfafbf531 | ||
|
|
11e7717519 | ||
|
|
a4539399d2 | ||
|
|
d14be81f18 | ||
|
|
44c4366bba | ||
|
|
20b115f99f | ||
|
|
e99ba5b6ae | ||
|
|
12d32ee8f2 | ||
|
|
0f423e8b60 | ||
|
|
37dba2495e | ||
|
|
32b1d62773 | ||
|
|
b366a85248 | ||
|
|
57008b9c94 | ||
|
|
8448b4ffa0 | ||
|
|
612e74faff | ||
|
|
afc225520b | ||
|
|
a437e52dac | ||
|
|
584d027e58 | ||
|
|
5877ca9a0a | ||
|
|
d176be7654 | ||
|
|
69d1534204 | ||
|
|
ee30bb3821 | ||
|
|
4a45204a4b | ||
|
|
03f673c647 | ||
|
|
7c846e40cf | ||
|
|
405980862c | ||
|
|
0f3ab6f0c0 | ||
|
|
e12890510f | ||
|
|
b0701da4bf | ||
|
|
048195e0e6 | ||
|
|
81425f1f06 | ||
|
|
af02ea27a0 | ||
|
|
2800dddbf6 | ||
|
|
43cd2c64ab | ||
|
|
5854eace7b | ||
|
|
399c0b0ff4 | ||
|
|
f49a778035 | ||
|
|
395549aec9 | ||
|
|
94dd6067ca | ||
|
|
aee5734e74 | ||
|
|
235db9dae5 | ||
|
|
84d22248a4 | ||
|
|
207468d0f0 | ||
|
|
1409e45f8d | ||
|
|
e8306d67b6 | ||
|
|
d60614ada7 | ||
|
|
a287d7b2b4 | ||
|
|
48d34f3f58 | ||
|
|
098924a8f9 | ||
|
|
2d2ff05987 | ||
|
|
32db78d64d | ||
|
|
ff4f7126f9 | ||
|
|
590d3bd4f8 | ||
|
|
abf0507307 | ||
|
|
89e5bee174 | ||
|
|
7fa8bec6b8 | ||
|
|
6459e84b80 | ||
|
|
e16470affd | ||
|
|
8f812b633b | ||
|
|
08eaf8d898 | ||
|
|
b9bc7af1d6 | ||
|
|
b75974e850 | ||
|
|
b1fa9ea672 | ||
|
|
0cac350493 | ||
|
|
b8ed2db0f0 | ||
|
|
5d9db17b9c | ||
|
|
038e2dee88 | ||
|
|
5b15fc2972 | ||
|
|
4035e2e262 | ||
|
|
011bc94e8c | ||
|
|
49448790ff | ||
|
|
9259bf65ef | ||
|
|
8819c78487 | ||
|
|
ba32d125ac | ||
|
|
6434c5341a | ||
|
|
a12a4f2f79 | ||
|
|
0eb379ce3d | ||
|
|
76f83800f7 | ||
|
|
f7d82d2ede | ||
|
|
779a0f3ce9 | ||
|
|
4df32a5cef | ||
|
|
074fd8088f | ||
|
|
713ccddcf4 | ||
|
|
7b4c942a1f | ||
|
|
11c357135b | ||
|
|
4055aca97f | ||
|
|
2fb1d19d93 | ||
|
|
94b07b300f | ||
|
|
ceb0de9b7f | ||
|
|
537094b5c8 | ||
|
|
856a62ee46 | ||
|
|
3cf20d9139 | ||
|
|
9bcbac3799 | ||
|
|
7d1e8e5e37 | ||
|
|
117e7b3eae | ||
|
|
2470b2b177 | ||
|
|
111ff3a198 | ||
|
|
79a8195ba5 | ||
|
|
52f503ce65 | ||
|
|
1b5d12bb2a | ||
|
|
69703371f1 | ||
|
|
32b5fde72e | ||
|
|
552b6727da | ||
|
|
95222a21d8 | ||
|
|
f5ed2d1619 | ||
|
|
0f65d1f59b | ||
|
|
2f142c9a39 | ||
|
|
56a5cf7ead | ||
|
|
aee8d6e5a5 | ||
|
|
ee3f8d5046 | ||
|
|
dc2c5f35e5 | ||
|
|
e1635a36c8 | ||
|
|
8fcc5fc9cc | ||
|
|
fdbd8b2992 | ||
|
|
5fe2e06733 | ||
|
|
9d1923661f | ||
|
|
7807a451a0 | ||
|
|
30cd64c68f | ||
|
|
6cc4f44199 | ||
|
|
a63f9b608e | ||
|
|
57b5578104 | ||
|
|
77e6644882 | ||
|
|
5323be8db1 | ||
|
|
c22d58616c | ||
|
|
9fcd8ce72d | ||
|
|
9906604a38 | ||
|
|
20742d1942 | ||
|
|
ac9bdd0fab | ||
|
|
17baaecc28 | ||
|
|
fa1a6d449e | ||
|
|
209b746f6a | ||
|
|
9bfcd52dd5 | ||
|
|
5c4d0523c4 | ||
|
|
c2b3680f1c | ||
|
|
7f7e95fdad | ||
|
|
4191a825b1 | ||
|
|
968f7200d6 | ||
|
|
0e2744750a | ||
|
|
4e575fcfe6 | ||
|
|
8404a70e22 | ||
|
|
0954b7fbd6 | ||
|
|
a5f145bb97 | ||
|
|
153b3de830 | ||
|
|
feb64444f0 | ||
|
|
6d79efe77f | ||
|
|
1dde163418 | ||
|
|
3ed1d8be4c | ||
|
|
f71d73e90c | ||
|
|
44119c82e9 | ||
|
|
3dc30b656c | ||
|
|
03208ec636 | ||
|
|
b21c5cac76 | ||
|
|
f22e0eadbb | ||
|
|
3bb2f9fd7b | ||
|
|
94de10d54c | ||
|
|
69e28e3723 | ||
|
|
ce7a892223 | ||
|
|
d51a4630fd | ||
|
|
3733145db5 | ||
|
|
6cfb42412c | ||
|
|
bb7beef6c9 | ||
|
|
cdc83d183c | ||
|
|
1df564d4a3 | ||
|
|
d80dc105ee | ||
|
|
57c53e43db | ||
|
|
25c901e013 | ||
|
|
705f2990e9 | ||
|
|
208dcc06cc | ||
|
|
d4740bd1a8 | ||
|
|
c19e819ec6 | ||
|
|
efc001ef9d | ||
|
|
ba40708240 | ||
|
|
8ad6d83bd6 | ||
|
|
8ef18033d3 | ||
|
|
6bbb337828 | ||
|
|
1111b7d10c | ||
|
|
40c8cafc49 | ||
|
|
e14c9e1e03 | ||
|
|
3176ca8e1f | ||
|
|
bf86052fe3 | ||
|
|
fa4bff885e | ||
|
|
962393a9e9 | ||
|
|
81958847a9 | ||
|
|
17bc5c5da2 | ||
|
|
b7962a0c1d | ||
|
|
e215c95600 | ||
|
|
df63a90c89 | ||
|
|
2eb36438e8 | ||
|
|
bb4f79fb08 | ||
|
|
b5ff44b003 | ||
|
|
9741804fda | ||
|
|
65a13bf5fc | ||
|
|
0c5b5169c7 | ||
|
|
7e8cd1a708 | ||
|
|
84ec892777 | ||
|
|
a0e039169c | ||
|
|
070721cae1 | ||
|
|
b9a619afa2 | ||
|
|
33fb99ca37 | ||
|
|
e126d6e825 | ||
|
|
ba1ccc92a8 | ||
|
|
5cf74e3f95 | ||
|
|
2246b58aa9 | ||
|
|
f0bd0f2c9b | ||
|
|
e6781f06ae | ||
|
|
ef7ac5f2f6 | ||
|
|
5dc4d4c4d1 | ||
|
|
87d80d8284 | ||
|
|
e216f54d6e | ||
|
|
cfa9c702d0 | ||
|
|
15fa6ae8b0 | ||
|
|
05ae16df8b | ||
|
|
34232ef956 | ||
|
|
da35a13d04 | ||
|
|
cdca0efd05 | ||
|
|
320bbfe8b2 | ||
|
|
bf42fd4fea | ||
|
|
958a1463e6 | ||
|
|
4138630fc4 | ||
|
|
91545f932c | ||
|
|
36cc93dacc | ||
|
|
43e777687d | ||
|
|
037a76f5c7 | ||
|
|
41c54a02eb | ||
|
|
7901c21843 | ||
|
|
257110bc64 | ||
|
|
e2072d35c8 | ||
|
|
4a303d3ffa | ||
|
|
57d8a90000 | ||
|
|
0d54a265d9 | ||
|
|
412a10256d | ||
|
|
215ea12e80 | ||
|
|
b72e208f27 | ||
|
|
0714809fd9 | ||
|
|
17d43453cc | ||
|
|
ce120ac194 | ||
|
|
f19bbb8d38 | ||
|
|
4f7cbb7cdf | ||
|
|
3672a4729d | ||
|
|
b0d1cd257c | ||
|
|
be23ef93eb | ||
|
|
07d3176178 | ||
|
|
b01020dc0e | ||
|
|
4e5fedb18f | ||
|
|
dcd1fcfcde | ||
|
|
fb777d4dbf | ||
|
|
7b1f4f7f34 | ||
|
|
d88eb339b4 | ||
|
|
a84ef7be66 | ||
|
|
fc798985fd | ||
|
|
df176c39f5 | ||
|
|
49b39fb3af | ||
|
|
d9e8cca867 | ||
|
|
bdead5c55d | ||
|
|
05b0525a4b | ||
|
|
fa502cdda3 | ||
|
|
dee345b618 | ||
|
|
d55f78829e | ||
|
|
8f4264e26a | ||
|
|
c79ce7237e | ||
|
|
eeec34b018 | ||
|
|
69acb24aee | ||
|
|
61afc74215 | ||
|
|
396f4be965 | ||
|
|
0f7541dfab | ||
|
|
4031fb4f0e |
8
.babelrc
8
.babelrc
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"presets": [["env", { "modules": false }]],
|
||||
"env": {
|
||||
"test": {
|
||||
"presets": [["env", { "targets": { "node": "current" } }]]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
node_modules
|
||||
.cache
|
||||
.idea
|
||||
.github
|
||||
dist
|
||||
.git
|
||||
static
|
||||
integration
|
||||
demo.gif
|
||||
e2e
|
||||
|
||||
|
||||
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -31,10 +31,17 @@ updates:
|
||||
schedule:
|
||||
interval: daily
|
||||
- package-ecosystem: npm
|
||||
directory: "/integration"
|
||||
directory: "/e2e"
|
||||
labels:
|
||||
- "npm"
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: daily
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/e2e"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
46
.github/workflows/deploy.yml
vendored
46
.github/workflows/deploy.yml
vendored
@@ -9,23 +9,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v2.4.1
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
run: pnpm install
|
||||
- name: Run Tests
|
||||
run: yarn test
|
||||
run: pnpm run test
|
||||
go-test:
|
||||
name: Go Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.17.x
|
||||
go-version: 1.18.x
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test SKIP_ASSET=1
|
||||
int-test:
|
||||
@@ -33,11 +37,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
- name: Build images
|
||||
run: docker-compose -f integration/docker-compose.test.yml build
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
|
||||
- name: Run tests
|
||||
run: docker-compose -f integration/docker-compose.test.yml run integration
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
buildx:
|
||||
needs: [go-test, npm-test, int-test]
|
||||
name: Release
|
||||
@@ -45,20 +49,18 @@ jobs:
|
||||
steps:
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
uses: docker/setup-buildx-action@v2.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.10.0
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2.7.0
|
||||
uses: docker/build-push-action@v3.2.0
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
@@ -73,14 +75,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v2.4.1
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
run: pnpm install
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: yarn release --github.release --no-increment --no-git --ci
|
||||
run: pnpm run release -- --github.release --no-increment --no-git --ci
|
||||
|
||||
20
.github/workflows/dev.yml
vendored
20
.github/workflows/dev.yml
vendored
@@ -2,31 +2,33 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
name: Push master container
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
name: Push container
|
||||
jobs:
|
||||
buildx:
|
||||
name: Push master
|
||||
name: Push branches and PRs
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle' }}
|
||||
steps:
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
uses: docker/setup-buildx-action@v2.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.10.0
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2.7.0
|
||||
uses: docker/build-push-action@v3.2.0
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
55
.github/workflows/test.yml
vendored
55
.github/workflows/test.yml
vendored
@@ -1,4 +1,10 @@
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
name: Test
|
||||
jobs:
|
||||
npm-test:
|
||||
@@ -6,23 +12,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v2.4.1
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
run: pnpm install
|
||||
- name: Run Tests
|
||||
run: yarn test
|
||||
run: pnpm run test
|
||||
go-test:
|
||||
name: Go Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.17.x
|
||||
go-version: 1.18.x
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test SKIP_ASSET=1
|
||||
int-test:
|
||||
@@ -30,8 +40,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2.3.5
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.2.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build images
|
||||
run: docker-compose -f integration/docker-compose.test.yml build
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
- name: Push images
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml push
|
||||
- name: Set commit message for push
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "GIT_LOG_MESSAGE<<EOF" >> $GITHUB_ENV
|
||||
git log -1 --pretty=%B ${GITHUB_SHA} >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Set commit message for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
echo "GIT_LOG_MESSAGE<<EOF" >> $GITHUB_ENV
|
||||
git log -1 --pretty=%B ${{github.event.pull_request.head.sha}} >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Run tests
|
||||
run: docker-compose -f integration/docker-compose.test.yml run integration
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,6 +3,7 @@ dist
|
||||
node_modules
|
||||
.cache
|
||||
static
|
||||
a_main-packr.go
|
||||
dozzle
|
||||
gin-bin
|
||||
coverage
|
||||
.pnpm-debug.log
|
||||
.vscode
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname $0)/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
pnpm lint-staged
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@@ -1,7 +1,8 @@
|
||||
# Build assets
|
||||
FROM node:16-alpine as node
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine as node
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN apk add --no-cache git openssh make g++ util-linux curl && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -9,21 +10,21 @@ WORKDIR /build
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch
|
||||
|
||||
# Copy files
|
||||
COPY package.json .* webpack*.js ./
|
||||
# Copy package.json and install dependencies
|
||||
COPY package.json ./
|
||||
RUN pnpm install -r --offline --ignore-scripts
|
||||
|
||||
# Copy assets to build
|
||||
# Copy assets and translations to build
|
||||
COPY .* vite.config.ts index.html ./
|
||||
COPY assets ./assets
|
||||
COPY locales ./locales
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install -r --offline
|
||||
|
||||
# Do the build
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM golang:1.17.2-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.19.2-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates && mkdir /dozzle
|
||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
|
||||
|
||||
WORKDIR /dozzle
|
||||
|
||||
@@ -32,20 +33,26 @@ COPY go.* ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy assets built with node
|
||||
COPY --from=node /build/static ./static
|
||||
COPY --from=node /build/dist ./dist
|
||||
|
||||
# Copy all other files
|
||||
COPY . .
|
||||
COPY analytics ./analytics
|
||||
COPY healthcheck ./healthcheck
|
||||
COPY docker ./docker
|
||||
COPY web ./web
|
||||
COPY main.go ./
|
||||
|
||||
# Args
|
||||
ARG TAG=dev
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Build binary
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG" -o dozzle
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG" -o dozzle
|
||||
|
||||
|
||||
FROM scratch
|
||||
|
||||
ENV PATH=/bin
|
||||
ENV PATH /bin
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=builder /dozzle/dozzle /dozzle
|
||||
|
||||
20
Makefile
20
Makefile
@@ -1,24 +1,24 @@
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@rm -rf static
|
||||
@rm -rf dist
|
||||
@go clean -i
|
||||
|
||||
.PHONY: static
|
||||
static:
|
||||
.PHONY: dist
|
||||
dist:
|
||||
@pnpm build
|
||||
|
||||
.PHONY: fake_static
|
||||
fake_static:
|
||||
.PHONY: fake_assets
|
||||
fake_assets:
|
||||
@echo 'Skipping asset build'
|
||||
@mkdir -p static
|
||||
@echo "assets build was skipped" > static/index.html
|
||||
@mkdir -p dist
|
||||
@echo "assets build was skipped" > dist/index.html
|
||||
|
||||
.PHONY: test
|
||||
test: fake_static
|
||||
test: fake_assets
|
||||
go test -cover ./...
|
||||
|
||||
.PHONY: build
|
||||
build: static
|
||||
build: dist
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w"
|
||||
|
||||
.PHONY: docker
|
||||
@@ -31,4 +31,4 @@ dev:
|
||||
|
||||
.PHONY: int
|
||||
int:
|
||||
docker-compose -f integration/docker-compose.test.yml up --build --force-recreate --exit-code-from integration
|
||||
docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
|
||||
38
README.md
38
README.md
@@ -6,8 +6,7 @@ Dozzle is a small lightweight application with a web based interface to monitor
|
||||
|
||||
[](https://goreportcard.com/report/github.com/amir20/dozzle)
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||

|
||||
|
||||
## Features
|
||||
@@ -59,6 +58,30 @@ Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo
|
||||
ports:
|
||||
- 9999:8080
|
||||
|
||||
|
||||
### Enabling health check
|
||||
|
||||
Dozzle doesn't enable healthcheck by default as it adds extra CPU usage. `healthcheck` can be enabled manually.
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
dozzle:
|
||||
container_name: dozzle
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
DOZZLE_LEVEL: trace
|
||||
healthcheck:
|
||||
test: [ "CMD", "/dozzle", "healthcheck" ]
|
||||
interval: 3s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
|
||||
#### Security
|
||||
|
||||
You can control the device Dozzle binds to by passing `--addr` parameter. For example,
|
||||
@@ -106,11 +129,8 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
|
||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||
| `--username` | `DOZZLE_USERNAME` | `""` |
|
||||
| `--password` | `DOZZLE_PASSWORD` | `""` |
|
||||
| `--key` | `DOZZLE_KEY` | `""` |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
|
||||
|
||||
Note: When using username and password `DOZZLE_KEY` is required for session management.
|
||||
|
||||
## Troubleshooting and FAQs
|
||||
|
||||
<details>
|
||||
@@ -169,8 +189,8 @@ Dozzle has a [special route](https://github.com/amir20/dozzle/blob/master/assets
|
||||
|
||||
To Build and test locally:
|
||||
|
||||
1. Install NodeJs.
|
||||
2. Install Go.
|
||||
3. Install [reflex](https://github.com/cespare/reflex) with `get -u github.com/cespare/reflex` outside of dozzle.
|
||||
4. Install node modules with `pnpm`.
|
||||
1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
|
||||
2. Install [Go](https://go.dev/doc/install).
|
||||
3. Install [reflex](https://github.com/cespare/reflex) with `go get -u github.com/cespare/reflex` outside of dozzle.
|
||||
4. Install node modules `pnpm install`.
|
||||
5. Do `pnpm dev`
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import EventSource from "eventsourcemock";
|
||||
import { shallowMount, RouterLinkStub, createLocalVue } from "@vue/test-utils";
|
||||
import Vuex from "vuex";
|
||||
import App from "./App";
|
||||
|
||||
jest.mock("./store/config.js", () => ({ base: "" }));
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe("<App />", () => {
|
||||
const stubs = { RouterLink: RouterLinkStub, "router-view": true, icon: true };
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
global.EventSource = EventSource;
|
||||
const state = {
|
||||
settings: { menuWidth: 15 },
|
||||
containers: [{ id: "abc", name: "Test 1" }],
|
||||
};
|
||||
|
||||
const getters = {
|
||||
visibleContainers(store) {
|
||||
return store.containers;
|
||||
},
|
||||
activeContainers() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
state,
|
||||
getters,
|
||||
});
|
||||
});
|
||||
|
||||
test("has right title", async () => {
|
||||
const wrapper = shallowMount(App, { stubs, store, localVue });
|
||||
wrapper.vm.$store.state.containers = [
|
||||
{ id: "abc", name: "Test 1" },
|
||||
{ id: "xyz", name: "Test 2" },
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.title).toContain("2 containers");
|
||||
});
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const wrapper = shallowMount(App, { stubs, store, localVue });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
194
assets/App.vue
194
assets/App.vue
@@ -1,182 +1,26 @@
|
||||
<template>
|
||||
<main>
|
||||
<mobile-menu v-if="isMobile && !authorizationNeeded"></mobile-menu>
|
||||
|
||||
<splitpanes @resized="onResized($event)">
|
||||
<pane min-size="10" :size="settings.menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
|
||||
<side-menu @search="showFuzzySearch"></side-menu>
|
||||
</pane>
|
||||
<pane min-size="10">
|
||||
<splitpanes>
|
||||
<pane class="has-min-height router-view">
|
||||
<router-view></router-view>
|
||||
</pane>
|
||||
<template v-if="!isMobile">
|
||||
<pane v-for="other in activeContainers" :key="other.id">
|
||||
<log-container
|
||||
:id="other.id"
|
||||
show-title
|
||||
scrollable
|
||||
closable
|
||||
@close="removeActiveContainer(other)"
|
||||
></log-container>
|
||||
</pane>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</pane>
|
||||
</splitpanes>
|
||||
<button
|
||||
@click="collapseNav = !collapseNav"
|
||||
class="button is-small is-rounded is-settings-control"
|
||||
:class="{ collapsed: collapseNav }"
|
||||
id="hide-nav"
|
||||
v-if="!isMobile && !authorizationNeeded"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :name="collapseNav ? 'chevron-right' : 'chevron-left'"></icon>
|
||||
</span>
|
||||
</button>
|
||||
</main>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters, mapState } from "vuex";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
<script lang="ts" setup>
|
||||
watchEffect(() => {
|
||||
if (smallerScrollbars.value) {
|
||||
document.documentElement.classList.add("has-custom-scrollbars");
|
||||
} else {
|
||||
document.documentElement.classList.remove("has-custom-scrollbars");
|
||||
}
|
||||
|
||||
import hotkeys from "hotkeys-js";
|
||||
|
||||
import LogContainer from "./components/LogContainer";
|
||||
import SideMenu from "./components/SideMenu";
|
||||
import MobileMenu from "./components/MobileMenu";
|
||||
|
||||
import PastTime from "./components/PastTime";
|
||||
import Icon from "./components/Icon";
|
||||
import FuzzySearchModal from "./components/FuzzySearchModal";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: {
|
||||
Icon,
|
||||
SideMenu,
|
||||
LogContainer,
|
||||
MobileMenu,
|
||||
Splitpanes,
|
||||
PastTime,
|
||||
Pane,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
title: "",
|
||||
collapseNav: false,
|
||||
};
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleTemplate: "%s - Dozzle",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (this.hasSmallerScrollbars) {
|
||||
document.documentElement.classList.add("has-custom-scrollbars");
|
||||
}
|
||||
if (this.hasLightTheme) {
|
||||
switch (lightTheme.value) {
|
||||
case "dark":
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
break;
|
||||
case "light":
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
this.menuWidth = this.settings.menuWidth;
|
||||
hotkeys("command+k, ctrl+k", (event, handler) => {
|
||||
event.preventDefault();
|
||||
this.showFuzzySearch();
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
hasSmallerScrollbars(newValue, oldValue) {
|
||||
if (newValue) {
|
||||
document.documentElement.classList.add("has-custom-scrollbars");
|
||||
} else {
|
||||
document.documentElement.classList.remove("has-custom-scrollbars");
|
||||
}
|
||||
},
|
||||
hasLightTheme(newValue, oldValue) {
|
||||
if (newValue) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
},
|
||||
visibleContainers() {
|
||||
this.title = `${this.visibleContainers.length} containers`;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(["isMobile", "settings", "containers", "authorizationNeeded"]),
|
||||
...mapGetters(["visibleContainers", "activeContainers"]),
|
||||
hasSmallerScrollbars() {
|
||||
return this.settings.smallerScrollbars;
|
||||
},
|
||||
hasLightTheme() {
|
||||
return this.settings.lightTheme;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
removeActiveContainer: "REMOVE_ACTIVE_CONTAINER",
|
||||
updateSetting: "UPDATE_SETTING",
|
||||
}),
|
||||
onResized(e) {
|
||||
if (e.length == 2) {
|
||||
const menuWidth = e[0].size;
|
||||
this.updateSetting({ menuWidth });
|
||||
}
|
||||
},
|
||||
showFuzzySearch() {
|
||||
this.$buefy.modal.open({
|
||||
parent: this,
|
||||
component: FuzzySearchModal,
|
||||
animation: "false",
|
||||
width: 600,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
::v-deep .splitpanes--vertical > .splitpanes__splitter {
|
||||
min-width: 3px;
|
||||
background: var(--border-color);
|
||||
&:hover {
|
||||
background: var(--border-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
}
|
||||
}
|
||||
|
||||
.button.has-no-border {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.has-min-height {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#hide-nav {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
&.collapsed {
|
||||
left: -40px;
|
||||
width: 60px;
|
||||
padding-left: 40px;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
|
||||
&:hover {
|
||||
left: -25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<App /> renders correctly 1`] = `
|
||||
<main>
|
||||
<!---->
|
||||
|
||||
<splitpanes-stub
|
||||
dblclicksplitter="true"
|
||||
pushotherpanes="true"
|
||||
>
|
||||
<pane-stub
|
||||
maxsize="100"
|
||||
minsize="10"
|
||||
size="15"
|
||||
>
|
||||
<side-menu-stub />
|
||||
</pane-stub>
|
||||
|
||||
<pane-stub
|
||||
maxsize="100"
|
||||
minsize="10"
|
||||
>
|
||||
<splitpanes-stub
|
||||
dblclicksplitter="true"
|
||||
pushotherpanes="true"
|
||||
>
|
||||
<pane-stub
|
||||
class="has-min-height router-view"
|
||||
maxsize="100"
|
||||
minsize="0"
|
||||
>
|
||||
<router-view-stub />
|
||||
</pane-stub>
|
||||
|
||||
</splitpanes-stub>
|
||||
</pane-stub>
|
||||
</splitpanes-stub>
|
||||
|
||||
<button
|
||||
class="button is-small is-rounded is-settings-control"
|
||||
id="hide-nav"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<icon-stub
|
||||
name="chevron-left"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</main>
|
||||
`;
|
||||
616
assets/auto-imports.d.ts
vendored
Normal file
616
assets/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,616 @@
|
||||
// Generated by 'unplugin-auto-import'
|
||||
export {}
|
||||
declare global {
|
||||
const $$: typeof import('vue/macros')['$$']
|
||||
const $: typeof import('vue/macros')['$']
|
||||
const $computed: typeof import('vue/macros')['$computed']
|
||||
const $customRef: typeof import('vue/macros')['$customRef']
|
||||
const $ref: typeof import('vue/macros')['$ref']
|
||||
const $shallowRef: typeof import('vue/macros')['$shallowRef']
|
||||
const $toRef: typeof import('vue/macros')['$toRef']
|
||||
const DEFAULT_SETTINGS: typeof import('./composables/settings')['DEFAULT_SETTINGS']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const arrayEquals: typeof import('./utils/index')['arrayEquals']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const config: typeof import('./stores/config')['default']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const flattenJSON: typeof import('./utils/index')['flattenJSON']
|
||||
const formatBytes: typeof import('./utils/index')['formatBytes']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const getDeep: typeof import('./utils/index')['getDeep']
|
||||
const h: typeof import('vue')['h']
|
||||
const hourStyle: typeof import('./composables/settings')['hourStyle']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isMobile: typeof import('./composables/media')['isMobile']
|
||||
const isObject: typeof import('./utils/index')['isObject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const lightTheme: typeof import('./composables/settings')['lightTheme']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const menuWidth: typeof import('./composables/settings')['menuWidth']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const persistentVisibleKeys: typeof import('./utils/index')['persistentVisibleKeys']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const search: typeof import('./composables/settings')['search']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const setTitle: typeof import('./composables/title')['setTitle']
|
||||
const settings: typeof import('./composables/settings')['settings']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const showAllContainers: typeof import('./composables/settings')['showAllContainers']
|
||||
const showTimestamp: typeof import('./composables/settings')['showTimestamp']
|
||||
const size: typeof import('./composables/settings')['size']
|
||||
const smallerScrollbars: typeof import('./composables/settings')['smallerScrollbars']
|
||||
const softWrap: typeof import('./composables/settings')['softWrap']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const stripVersion: typeof import('./utils/index')['stripVersion']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useContainerStore: typeof import('./stores/container')['useContainerStore']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useLogStream: typeof import('./composables/eventsource')['useLogStream']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSearchFilter: typeof import('./composables/search')['useSearchFilter']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
||||
const useToString: typeof import('@vueuse/core')['useToString']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useVisibleFilter: typeof import('./composables/visible')['useVisibleFilter']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
}
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
readonly $$: UnwrapRef<typeof import('vue/macros')['$$']>
|
||||
readonly $: UnwrapRef<typeof import('vue/macros')['$']>
|
||||
readonly $computed: UnwrapRef<typeof import('vue/macros')['$computed']>
|
||||
readonly $customRef: UnwrapRef<typeof import('vue/macros')['$customRef']>
|
||||
readonly $ref: UnwrapRef<typeof import('vue/macros')['$ref']>
|
||||
readonly $shallowRef: UnwrapRef<typeof import('vue/macros')['$shallowRef']>
|
||||
readonly $toRef: UnwrapRef<typeof import('vue/macros')['$toRef']>
|
||||
readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./composables/settings')['DEFAULT_SETTINGS']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
|
||||
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
|
||||
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
|
||||
readonly config: UnwrapRef<typeof import('./stores/config')['default']>
|
||||
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
|
||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
|
||||
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./composables/settings')['hourStyle']>
|
||||
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
|
||||
readonly isMobile: UnwrapRef<typeof import('./composables/media')['isMobile']>
|
||||
readonly isObject: UnwrapRef<typeof import('./utils/index')['isObject']>
|
||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./composables/settings')['lightTheme']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly menuWidth: UnwrapRef<typeof import('./composables/settings')['menuWidth']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
|
||||
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
|
||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeys: UnwrapRef<typeof import('./utils/index')['persistentVisibleKeys']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
|
||||
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
|
||||
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
|
||||
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
|
||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
|
||||
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
|
||||
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
|
||||
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
||||
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
||||
readonly search: UnwrapRef<typeof import('./composables/settings')['search']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly setTitle: UnwrapRef<typeof import('./composables/title')['setTitle']>
|
||||
readonly settings: UnwrapRef<typeof import('./composables/settings')['settings']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly showAllContainers: UnwrapRef<typeof import('./composables/settings')['showAllContainers']>
|
||||
readonly showTimestamp: UnwrapRef<typeof import('./composables/settings')['showTimestamp']>
|
||||
readonly size: UnwrapRef<typeof import('./composables/settings')['size']>
|
||||
readonly smallerScrollbars: UnwrapRef<typeof import('./composables/settings')['smallerScrollbars']>
|
||||
readonly softWrap: UnwrapRef<typeof import('./composables/settings')['softWrap']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly stripVersion: UnwrapRef<typeof import('./utils/index')['stripVersion']>
|
||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
|
||||
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
|
||||
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
|
||||
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
|
||||
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
|
||||
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
|
||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
|
||||
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
|
||||
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
|
||||
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
|
||||
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
|
||||
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
|
||||
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
|
||||
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
|
||||
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
|
||||
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
|
||||
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
|
||||
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
|
||||
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
|
||||
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
|
||||
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
|
||||
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
|
||||
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
|
||||
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
|
||||
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
|
||||
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
|
||||
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
|
||||
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
|
||||
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
|
||||
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
|
||||
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
|
||||
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
|
||||
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
|
||||
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
|
||||
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
|
||||
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
|
||||
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
|
||||
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
|
||||
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
|
||||
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
|
||||
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
|
||||
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
|
||||
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
|
||||
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
|
||||
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
|
||||
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
|
||||
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
|
||||
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
|
||||
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
|
||||
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
|
||||
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
|
||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
|
||||
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
|
||||
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
|
||||
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
|
||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useLogStream: UnwrapRef<typeof import('./composables/eventsource')['useLogStream']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
|
||||
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
||||
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
||||
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
||||
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
||||
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
||||
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
|
||||
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
|
||||
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
|
||||
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
|
||||
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
|
||||
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
|
||||
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
|
||||
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
|
||||
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
|
||||
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
|
||||
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 useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||
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 useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
|
||||
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
|
||||
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
|
||||
readonly useSearchFilter: UnwrapRef<typeof import('./composables/search')['useSearchFilter']>
|
||||
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
|
||||
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
|
||||
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
|
||||
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
|
||||
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
|
||||
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
|
||||
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
|
||||
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
|
||||
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
||||
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
||||
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
|
||||
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
||||
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
|
||||
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
|
||||
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
|
||||
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
|
||||
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
|
||||
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
|
||||
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
|
||||
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
|
||||
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
|
||||
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
|
||||
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
|
||||
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
|
||||
readonly useVisibleFilter: UnwrapRef<typeof import('./composables/visible')['useVisibleFilter']>
|
||||
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
|
||||
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
|
||||
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
|
||||
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
|
||||
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
|
||||
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
|
||||
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
||||
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
|
||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
|
||||
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
|
||||
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
|
||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
|
||||
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
|
||||
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
|
||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
|
||||
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
|
||||
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
|
||||
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
|
||||
}
|
||||
}
|
||||
51
assets/components.d.ts
vendored
Normal file
51
assets/components.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default']
|
||||
CilColumns: typeof import('~icons/cil/columns')['default']
|
||||
CilFindInPage: typeof import('~icons/cil/find-in-page')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default']
|
||||
ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default']
|
||||
CpuSparkline: typeof import('./components/StatSparkline.vue')['default']
|
||||
DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default']
|
||||
DropdownMenu: typeof import('./components/DropdownMenu.vue')['default']
|
||||
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
LogActionsToolbar: typeof import('./components/LogViewer/LogActionsToolbar.vue')['default']
|
||||
LogContainer: typeof import('./components/LogViewer/LogContainer.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||
LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default']
|
||||
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
|
||||
MdiLightChevronDoubleDown: typeof import('~icons/mdi-light/chevron-double-down')['default']
|
||||
MdiLightChevronLeft: typeof import('~icons/mdi-light/chevron-left')['default']
|
||||
MdiLightChevronRight: typeof import('~icons/mdi-light/chevron-right')['default']
|
||||
MdiLightCog: typeof import('~icons/mdi-light/cog')['default']
|
||||
MdiLightMagnify: typeof import('~icons/mdi-light/magnify')['default']
|
||||
MobileMenu: typeof import('./components/MobileMenu.vue')['default']
|
||||
OcticonContainer24: typeof import('~icons/octicon/container24')['default']
|
||||
OcticonDownload24: typeof import('~icons/octicon/download24')['default']
|
||||
OcticonTrash24: typeof import('~icons/octicon/trash24')['default']
|
||||
PastTime: typeof import('./components/PastTime.vue')['default']
|
||||
RelativeTime: typeof import('./components/RelativeTime.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
|
||||
Search: typeof import('./components/Search.vue')['default']
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
|
||||
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
|
||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile">
|
||||
<div class="column is-narrow has-text-weight-bold">
|
||||
{{ state }}
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="stat.memoryUsage !== null">
|
||||
<span class="has-text-weight-light">mem</span>
|
||||
<span class="has-text-weight-bold">
|
||||
{{ formatBytes(stat.memoryUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow" v-if="stat.cpu !== null">
|
||||
<span class="has-text-weight-light">load</span>
|
||||
<span class="has-text-weight-bold"> {{ stat.cpu }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
stat: Object,
|
||||
state: String,
|
||||
},
|
||||
name: "ContainerStat",
|
||||
methods: {
|
||||
formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="columns is-marginless has-text-weight-bold is-family-monospace">
|
||||
<span class="column is-ellipsis"
|
||||
>{{ container.name }} <span class="tag is-dark">{{ container.image }}</span></span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
container: Object,
|
||||
},
|
||||
name: "ContainerTitle",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
44
assets/components/DropdownMenu.vue
Normal file
44
assets/components/DropdownMenu.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="dropdown is-hoverable">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
|
||||
<span class="icon">
|
||||
<mdi-dots-vertical />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.minimal .button {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
padding: 0.1em;
|
||||
height: 100%;
|
||||
|
||||
& > .icon {
|
||||
height: 100%;
|
||||
& > svg {
|
||||
align-self: flex-start;
|
||||
height: 0.85em;
|
||||
}
|
||||
}
|
||||
}
|
||||
.is-top {
|
||||
& .dropdown-menu {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.is-last .dropdown-menu {
|
||||
top: -30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,111 +1,108 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<b-autocomplete
|
||||
<o-autocomplete
|
||||
ref="autocomplete"
|
||||
v-model="query"
|
||||
placeholder="Search containers using ⌘ + k, ⌃k"
|
||||
field="name"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
open-on-focus
|
||||
keep-first
|
||||
expanded
|
||||
:data="results"
|
||||
:data="data"
|
||||
@select="selected"
|
||||
>
|
||||
<template slot-scope="props">
|
||||
<template #default="{ option: item }">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<span class="icon is-small" :class="props.option.state"><icon name="crate"></icon></span>
|
||||
<span class="icon is-small" :class="item.state">
|
||||
<octicon-container-24 />
|
||||
</span>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
{{ props.option.name }}
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="media-right">
|
||||
<span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column">
|
||||
<icon name="column"></icon>
|
||||
<span
|
||||
class="icon is-small column-icon"
|
||||
@click.stop.prevent="addColumn(item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
>
|
||||
<cil-columns />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</b-autocomplete>
|
||||
</o-autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapActions } from "vuex";
|
||||
import fuzzysort from "fuzzysort";
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
|
||||
import PastTime from "./PastTime";
|
||||
import Icon from "./Icon";
|
||||
const { maxResults: resultLimit = 20 } = defineProps<{
|
||||
maxResults?: number;
|
||||
}>();
|
||||
|
||||
export default {
|
||||
props: {
|
||||
maxResults: {
|
||||
default: 20,
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
const autocomplete = ref<HTMLElement>();
|
||||
const router = useRouter();
|
||||
const store = useContainerStore();
|
||||
const { containers } = storeToRefs(store);
|
||||
|
||||
const list = computed(() => {
|
||||
return containers.value.map(({ id, created, name, state }) => {
|
||||
return {
|
||||
query: "",
|
||||
id,
|
||||
created,
|
||||
name,
|
||||
state,
|
||||
};
|
||||
},
|
||||
name: "FuzzySearchModal",
|
||||
components: {
|
||||
Icon,
|
||||
PastTime,
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => this.$refs.autocomplete.focus());
|
||||
},
|
||||
watch: {},
|
||||
methods: {
|
||||
...mapActions({
|
||||
appendActiveContainer: "APPEND_ACTIVE_CONTAINER",
|
||||
}),
|
||||
selected(item) {
|
||||
this.$router.push({ name: "container", params: { id: item.id, name: item.name } });
|
||||
this.$emit("close");
|
||||
},
|
||||
addColumn(container) {
|
||||
this.appendActiveContainer(container);
|
||||
this.$emit("close");
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(["containers"]),
|
||||
preparedContainers() {
|
||||
return this.containers.map((c) => ({
|
||||
name: c.name,
|
||||
id: c.id,
|
||||
created: c.created,
|
||||
state: c.state,
|
||||
preparedName: fuzzysort.prepare(c.name),
|
||||
}));
|
||||
},
|
||||
results() {
|
||||
const options = {
|
||||
limit: this.maxResults,
|
||||
key: "preparedName",
|
||||
};
|
||||
if (this.query) {
|
||||
const results = fuzzysort.go(this.query, this.preparedContainers, options);
|
||||
results.forEach((result) => {
|
||||
if (result.obj.state === "running") {
|
||||
result.score += 1;
|
||||
}
|
||||
});
|
||||
return results.sort((a, b) => b.score - a.score).map((i) => i.obj);
|
||||
});
|
||||
});
|
||||
|
||||
const { results } = useFuse(query, list, {
|
||||
fuseOptions: { keys: ["name"], includeScore: true },
|
||||
resultLimit,
|
||||
matchAllWhenSearchEmpty: true,
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
return results.value
|
||||
.sort((a, b) => {
|
||||
if (a.score === b.score) {
|
||||
if (a.item.state === "running" && b.item.state !== "running") {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else if (a.score && b.score) {
|
||||
return a.score - b.score;
|
||||
} else {
|
||||
return [...this.containers].sort((a, b) => b.created - a.created);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
});
|
||||
watchOnce(autocomplete, () => autocomplete.value?.focus());
|
||||
|
||||
function selected({ id }: { id: string }) {
|
||||
router.push({ name: "container-id", params: { id } });
|
||||
emit("close");
|
||||
}
|
||||
function addColumn(container: Container) {
|
||||
store.appendActiveContainer(container);
|
||||
emit("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
min-height: 400px;
|
||||
width: 580px;
|
||||
}
|
||||
|
||||
.running {
|
||||
@@ -122,7 +119,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep a.dropdown-item {
|
||||
:deep(a.dropdown-item) {
|
||||
padding-right: 1em;
|
||||
.media-right {
|
||||
visibility: hidden;
|
||||
@@ -131,4 +128,8 @@ export default {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<template functional>
|
||||
<svg class="icomoon" :class="['icon-' + props.name]">
|
||||
<use :href="'#icon-' + props.name"></use>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
name: "Icon",
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.icomoon {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
stroke-width: 0;
|
||||
stroke: currentColor;
|
||||
fill: currentColor;
|
||||
|
||||
.icon:not(.keep-size) & {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="observer" class="infinte-loader">
|
||||
<div ref="root" class="infinte-loader">
|
||||
<div class="spinner" v-show="isLoading">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
@@ -8,40 +8,32 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "InfiniteLoader",
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
onLoadMore: Function,
|
||||
enabled: Boolean,
|
||||
},
|
||||
mounted() {
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (this.onLoadMore && this.enabled) {
|
||||
const scrollingParent = this.$el.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
this.isLoading = true;
|
||||
await this.onLoadMore();
|
||||
this.isLoading = false;
|
||||
this.$nextTick(() => (scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight));
|
||||
}
|
||||
},
|
||||
{ threshholds: 1 }
|
||||
);
|
||||
<script lang="ts" setup>
|
||||
const { onLoadMore = () => {}, enabled } = defineProps<{
|
||||
onLoadMore: () => void;
|
||||
enabled: boolean;
|
||||
}>();
|
||||
|
||||
intersectionObserver.observe(this.$refs.observer);
|
||||
const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
|
||||
},
|
||||
};
|
||||
const observer = new IntersectionObserver(async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (onLoadMore && enabled) {
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await onLoadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => observer.observe(root.value!));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.infinte-loader {
|
||||
min-height: 1px;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<scrollable-view :scrollable="scrollable" v-if="container">
|
||||
<template v-slot:header v-if="showTitle">
|
||||
<div class="mr-0 columns is-vcentered is-hidden-mobile">
|
||||
<div class="column is-clipped">
|
||||
<container-title :container="container" @close="$emit('close')"></container-title>
|
||||
</div>
|
||||
<div class="column is-clipped">
|
||||
<container-stat :stat="container.stat" :state="container.state"></container-stat>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<a
|
||||
class="button is-small is-outlined"
|
||||
id="download"
|
||||
:href="`${base}/api/logs/download?id=${container.id}`"
|
||||
download
|
||||
>
|
||||
<span class="icon">
|
||||
<icon name="save"></icon>
|
||||
</span>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="closable">
|
||||
<button class="delete is-medium" @click="$emit('close')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot="{ setLoading }">
|
||||
<log-viewer-with-source :id="id" @loading-more="setLoading($event)"></log-viewer-with-source>
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LogViewerWithSource from "./LogViewerWithSource";
|
||||
import ScrollableView from "./ScrollableView";
|
||||
import ContainerTitle from "./ContainerTitle";
|
||||
import ContainerStat from "./ContainerStat";
|
||||
import Icon from "./Icon";
|
||||
import config from "../store/config";
|
||||
import containerMixin from "./mixins/container";
|
||||
|
||||
export default {
|
||||
mixins: [containerMixin],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
name: "LogContainer",
|
||||
components: {
|
||||
LogViewerWithSource,
|
||||
ScrollableView,
|
||||
ContainerTitle,
|
||||
ContainerStat,
|
||||
Icon,
|
||||
},
|
||||
computed: {
|
||||
base() {
|
||||
return config.base;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
button.delete {
|
||||
background-color: var(--scheme-main-ter);
|
||||
opacity: 0.6;
|
||||
&:after,
|
||||
&:before {
|
||||
background-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#download.button {
|
||||
.icon {
|
||||
margin-right: 5px;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,246 +0,0 @@
|
||||
import debounce from "lodash.debounce";
|
||||
import EventSource from "eventsourcemock";
|
||||
import { sources } from "eventsourcemock";
|
||||
import { shallowMount, mount, createLocalVue } from "@vue/test-utils";
|
||||
import Vuex from "vuex";
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
|
||||
jest.mock("lodash.debounce", () =>
|
||||
jest.fn((fn) => {
|
||||
fn.cancel = () => {};
|
||||
return fn;
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock("../store/config.js", () => ({ base: "" }));
|
||||
|
||||
describe("<LogEventSource />", () => {
|
||||
beforeEach(() => {
|
||||
global.EventSource = EventSource;
|
||||
window.scrollTo = jest.fn();
|
||||
const observe = jest.fn();
|
||||
const disconnect = jest.fn();
|
||||
global.IntersectionObserver = jest.fn(() => ({
|
||||
observe,
|
||||
disconnect,
|
||||
}));
|
||||
debounce.mockClear();
|
||||
});
|
||||
|
||||
function createLogEventSource({ hourStyle = "auto", searchFilter = null } = {}) {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
localVue.component("log-viewer", LogViewer);
|
||||
|
||||
const state = { searchFilter, settings: { size: "medium", showTimestamp: true, hourStyle } };
|
||||
const getters = {
|
||||
allContainersById() {
|
||||
return {
|
||||
abc: { state: "running" },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state,
|
||||
getters,
|
||||
});
|
||||
|
||||
return mount(LogEventSource, {
|
||||
localVue,
|
||||
store,
|
||||
scopedSlots: {
|
||||
default: `
|
||||
<log-viewer :messages="props.messages"></log-viewer>
|
||||
`,
|
||||
},
|
||||
propsData: { id: "abc" },
|
||||
});
|
||||
}
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should connect to EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
test("should close EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
wrapper.destroy();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
|
||||
});
|
||||
|
||||
test("should parse messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
});
|
||||
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2019-06-12T10:55:42.459034602Z");
|
||||
expect(messageWithoutKey).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"message": "\\"This is a message.\\"",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("should parse messages with loki's timestamp format", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ data: `2020-04-27T12:35:43.272974324+02:00 xxxxx` });
|
||||
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2020-04-27T12:35:43.272974324+02:00");
|
||||
expect(messageWithoutKey).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"date": 2020-04-27T10:35:43.272Z,
|
||||
"message": "xxxxx",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("should pass messages to slot", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
});
|
||||
const [message, _] = wrapper.findComponent(LogViewer).vm.messages;
|
||||
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2019-06-12T10:55:42.459034602Z");
|
||||
|
||||
expect(messageWithoutKey).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"message": "\\"This is a message.\\"",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
const RealDate = Date;
|
||||
beforeAll(() => {
|
||||
global.Date = class extends RealDate {
|
||||
constructor(arg) {
|
||||
if (arg) {
|
||||
return new RealDate(arg);
|
||||
} else {
|
||||
return new RealDate(1560336936000);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
afterAll(() => (global.Date = RealDate));
|
||||
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">"This is a message."</span></li>
|
||||
</ul>
|
||||
`);
|
||||
});
|
||||
|
||||
test("should render messages with color", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z \x1b[30mblack\x1b[37mwhite`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text"><span style="color:#000">black<span style="color:#AAA">white</span></span></span></li>
|
||||
</ul>
|
||||
`);
|
||||
});
|
||||
|
||||
test("should render messages with html entities", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z <test>foo bar</test>`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text"><test>foo bar</test></span></li>
|
||||
</ul>
|
||||
`);
|
||||
});
|
||||
|
||||
test("should render dates with 12 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 11:55:42 PM</time></span> <span class="text"><test>foo bar</test></span></li>
|
||||
</ul>
|
||||
`);
|
||||
});
|
||||
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T23:55:42.459Z">today at 23:55:42</time></span> <span class="text"><test>foo bar</test></span></li>
|
||||
</ul>
|
||||
`);
|
||||
});
|
||||
|
||||
test("should render messages with filter", async () => {
|
||||
const wrapper = createLogEventSource({ searchFilter: "test" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-11T10:55:42.459034602Z Foo bar`,
|
||||
});
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z This is a test <hi></hi>`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("ul.events")).toMatchInlineSnapshot(`
|
||||
<ul class="events medium">
|
||||
<li><span class="date"><time datetime="2019-06-12T10:55:42.459Z">today at 10:55:42 AM</time></span> <span class="text">This is a <mark>test</mark> <hi></hi></span></li>
|
||||
</ul>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from "lodash.debounce";
|
||||
import InfiniteLoader from "./InfiniteLoader";
|
||||
import config from "../store/config";
|
||||
import containerMixin from "./mixins/container";
|
||||
|
||||
export default {
|
||||
props: ["id"],
|
||||
mixins: [containerMixin],
|
||||
name: "LogEventSource",
|
||||
components: {
|
||||
InfiniteLoader,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
buffer: [],
|
||||
es: null,
|
||||
lastEventId: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.flushBuffer = debounce(this.flushNow, 250, { maxWait: 1000 });
|
||||
this.loadLogs();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.es.close();
|
||||
},
|
||||
methods: {
|
||||
loadLogs() {
|
||||
this.reset();
|
||||
this.connect();
|
||||
},
|
||||
onContainerStopped() {
|
||||
this.es.close();
|
||||
this.buffer.push({ event: "container-stopped", message: "Container stopped", date: new Date(), key: new Date() });
|
||||
this.flushBuffer();
|
||||
this.flushBuffer.flush();
|
||||
},
|
||||
onMessage(e) {
|
||||
this.lastEventId = e.lastEventId;
|
||||
this.buffer.push(this.parseMessage(e.data));
|
||||
this.flushBuffer();
|
||||
},
|
||||
onContainerStateChange(newValue, oldValue) {
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
this.buffer.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
key: new Date(),
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
},
|
||||
connect() {
|
||||
this.es = new EventSource(`${config.base}/api/logs/stream?id=${this.id}&lastEventId=${this.lastEventId ?? ""}`);
|
||||
this.es.addEventListener("container-stopped", (e) => this.onContainerStopped());
|
||||
this.es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
this.es.onmessage = (e) => this.onMessage(e);
|
||||
},
|
||||
flushNow() {
|
||||
this.messages.push(...this.buffer);
|
||||
this.buffer = [];
|
||||
},
|
||||
reset() {
|
||||
if (this.es) {
|
||||
this.es.close();
|
||||
}
|
||||
this.flushBuffer.cancel();
|
||||
this.es = null;
|
||||
this.messages = [];
|
||||
this.buffer = [];
|
||||
this.lastEventId = null;
|
||||
},
|
||||
async loadOlderLogs() {
|
||||
if (this.messages.length < 300) return;
|
||||
|
||||
this.$emit("loading-more", true);
|
||||
const to = this.messages[0].date;
|
||||
const last = this.messages[299].date;
|
||||
const delta = to - last;
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${this.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => this.parseMessage(line));
|
||||
this.messages.unshift(...newMessages);
|
||||
}
|
||||
this.$emit("loading-more", false);
|
||||
},
|
||||
parseMessage(data) {
|
||||
let i = data.indexOf(" ");
|
||||
if (i == -1) {
|
||||
i = data.length;
|
||||
}
|
||||
const key = data.substring(0, i);
|
||||
const date = new Date(key);
|
||||
const message = data.substring(i + 1);
|
||||
return { key, date, message };
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
id(newValue, oldValue) {
|
||||
if (oldValue !== newValue) {
|
||||
this.loadLogs();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<ul class="events" :class="settings.size">
|
||||
<li v-for="item in filtered" :key="item.key" :data-event="item.event">
|
||||
<span class="date" v-if="settings.showTimestamp"><relative-time :date="item.date"></relative-time></span>
|
||||
<span class="text" v-html="colorize(item.message)"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import DOMPurify from "dompurify";
|
||||
import RelativeTime from "./RelativeTime";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
|
||||
|
||||
if (window.trustedTypes && trustedTypes.createPolicy) {
|
||||
trustedTypes.createPolicy("default", {
|
||||
createHTML: (string, sink) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
props: ["messages"],
|
||||
name: "LogViewer",
|
||||
components: { RelativeTime },
|
||||
data() {
|
||||
return {
|
||||
showSearch: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
colorize: function (value) {
|
||||
return ansiConvertor.toHtml(value).replace("<mark>", "<mark>").replace("</mark>", "</mark>");
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(["searchFilter", "settings"]),
|
||||
filtered() {
|
||||
const { searchFilter, messages } = this;
|
||||
if (searchFilter) {
|
||||
const isSmartCase = searchFilter === searchFilter.toLowerCase();
|
||||
try {
|
||||
const regex = isSmartCase ? new RegExp(searchFilter, "i") : new RegExp(searchFilter);
|
||||
return messages
|
||||
.filter((d) => d.message.match(regex))
|
||||
.map((d) => ({
|
||||
...d,
|
||||
message: d.message.replace(regex, "<mark>$&</mark>"),
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
console.info(`Ignoring SytaxError from search.`, e);
|
||||
return messages;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.events {
|
||||
padding: 1em;
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, monaco, Menlo, monospace;
|
||||
|
||||
& > li {
|
||||
word-wrap: break-word;
|
||||
line-height: 130%;
|
||||
&:last-child {
|
||||
scroll-snap-align: end;
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
&[data-event="container-stopped"] {
|
||||
color: #f14668;
|
||||
}
|
||||
&[data-event="container-started"] {
|
||||
color: hsl(141, 53%, 53%);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 120%;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
background-color: #262626;
|
||||
color: #258ccd;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background-color: #f0f0f0;
|
||||
color: #009900;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
::v-deep mark {
|
||||
border-radius: 2px;
|
||||
background-color: var(--secondary-color);
|
||||
animation: pops 200ms ease-out;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
0% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
assets/components/LogViewer/ComplexLogItem.vue
Normal file
63
assets/components/LogViewer/ComplexLogItem.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="columns is-1 is-variable">
|
||||
<div class="column is-narrow" v-if="showTimestamp">
|
||||
<log-date :date="logEntry.date"></log-date>
|
||||
</div>
|
||||
<div class="column">
|
||||
<ul class="fields" :class="{ expanded }" @click="expanded = !expanded">
|
||||
<li v-for="(value, name) in validValues(logEntry.message)">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<span class="has-text-weight-bold" v-html="markSearch(value)"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<field-list :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { type ComplexLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: ComplexLogEntry;
|
||||
visibleKeys: string[][];
|
||||
}>();
|
||||
|
||||
let expanded = $ref(false);
|
||||
|
||||
function validValues(obj: Record<string, any>) {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fields {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
&::after {
|
||||
content: "expand json";
|
||||
color: var(--secondary-color);
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded:hover {
|
||||
&::after {
|
||||
content: "collapse json";
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
& + li {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
assets/components/LogViewer/ContainerStat.vue
Normal file
79
assets/components/LogViewer/ContainerStat.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile is-vcentered" v-if="container.stat">
|
||||
<div class="column is-narrow has-text-weight-bold">
|
||||
{{ container.state }}
|
||||
</div>
|
||||
<div class="column is-narrow has-text-centered is-relative">
|
||||
<div class="has-border">
|
||||
<stat-sparkline :data="memoryData"></stat-sparkline>
|
||||
</div>
|
||||
|
||||
<div class="has-background-body-color is-top-left">
|
||||
<span class="has-text-weight-light has-spacer">mem</span>
|
||||
<span class="has-text-weight-bold">
|
||||
{{ formatBytes(container.stat.memoryUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow has-text-centered is-relative">
|
||||
<div class="has-border">
|
||||
<stat-sparkline :data="cpuData"></stat-sparkline>
|
||||
</div>
|
||||
<div class="has-background-body-color is-top-left">
|
||||
<span class="has-text-weight-light has-spacer">load</span>
|
||||
<span class="has-text-weight-bold"> {{ container.stat.cpu }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { type ComputedRef } from "vue";
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
|
||||
const cpuData = computedWithControl(
|
||||
() => container.value.getLastStat(),
|
||||
() => {
|
||||
const history = container.value.getStatHistory();
|
||||
return history.map((stat, i) => ({ x: history.length - i, y: stat.snapshot.cpu }));
|
||||
}
|
||||
);
|
||||
|
||||
const memoryData = computedWithControl(
|
||||
() => container.value.getLastStat(),
|
||||
() => {
|
||||
const history = container.value.getStatHistory();
|
||||
return history.map((stat, i) => ({ x: history.length - i, y: stat.snapshot.memory }));
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.has-spacer {
|
||||
&::after {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
.has-border {
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 3px;
|
||||
padding: 1px 1px 0 1px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
|
||||
.has-background-body-color {
|
||||
background-color: var(--body-background-color);
|
||||
}
|
||||
|
||||
.is-top-left {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0.75em;
|
||||
}
|
||||
</style>
|
||||
17
assets/components/LogViewer/ContainerTitle.vue
Normal file
17
assets/components/LogViewer/ContainerTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="columns is-marginless has-text-weight-bold is-family-monospace">
|
||||
<span class="column is-ellipsis">
|
||||
{{ container.name }}
|
||||
<span class="tag is-dark">{{ container.image.replace(/@sha.*/, "") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { type ComputedRef } from "vue";
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
24
assets/components/LogViewer/DockerEventLogItem.vue
Normal file
24
assets/components/LogViewer/DockerEventLogItem.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span class="text" :data-event="logEntry.event" v-html="logEntry.message"></span>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { DockerEventLogEntry } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
logEntry: DockerEventLogEntry;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
span {
|
||||
&[data-event="container-stopped"] {
|
||||
color: #f14668;
|
||||
}
|
||||
&[data-event="container-started"] {
|
||||
color: hsl(141, 53%, 53%);
|
||||
}
|
||||
&.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
assets/components/LogViewer/FieldList.vue
Normal file
76
assets/components/LogViewer/FieldList.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<ul v-if="expanded" ref="root">
|
||||
<li v-for="(value, name) in fields">
|
||||
<template v-if="isObject(value)">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<field-list
|
||||
:fields="value"
|
||||
:parent-key="parentKey.concat(name)"
|
||||
:visible-keys="visibleKeys"
|
||||
expanded
|
||||
></field-list>
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span>[
|
||||
<span class="has-text-weight-bold" v-for="(item, index) in value">
|
||||
{{ item }}
|
||||
<span v-if="index !== value.length - 1">,</span>
|
||||
</span>
|
||||
]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span><span class="has-text-weight-bold">{{ value }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { arrayEquals, isObject } from "@/utils";
|
||||
|
||||
const {
|
||||
fields,
|
||||
expanded = false,
|
||||
parentKey = [],
|
||||
visibleKeys = [],
|
||||
} = defineProps<{
|
||||
fields: Record<string, any>;
|
||||
expanded?: boolean;
|
||||
parentKey?: string[];
|
||||
visibleKeys?: string[][];
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
async function toggleField(field: string) {
|
||||
const index = fieldIndex(field);
|
||||
|
||||
if (index > -1) {
|
||||
visibleKeys.splice(index, 1);
|
||||
} else {
|
||||
visibleKeys.push(parentKey.concat(field));
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
root.value?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
|
||||
function hasField(field: string) {
|
||||
return fieldIndex(field) > -1;
|
||||
}
|
||||
|
||||
function fieldIndex(field: string) {
|
||||
const path = parentKey.concat(field);
|
||||
return visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
}
|
||||
</style>
|
||||
69
assets/components/LogViewer/LogActionsToolbar.vue
Normal file
69
assets/components/LogViewer/LogActionsToolbar.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<dropdown-menu class="is-right">
|
||||
<a class="dropdown-item" @click="onClearClicked">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<octicon-trash-24 class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">{{ $t("toolbar.clear") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}`">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<octicon-download-24 class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">{{ $t("toolbar.download") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<hr class="dropdown-divider" />
|
||||
<a class="dropdown-item" @click="showSearch = true">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<mdi-light-magnify class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">{{ $t("toolbar.search") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</dropdown-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ComputedRef } from "vue";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { base } = config;
|
||||
|
||||
const { onClearClicked = (e: Event) => {} } = defineProps<{
|
||||
onClearClicked: (e: Event) => void;
|
||||
}>();
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#download.button,
|
||||
#clear.button {
|
||||
.icon {
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
assets/components/LogViewer/LogContainer.vue
Normal file
70
assets/components/LogViewer/LogContainer.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<scrollable-view :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile">
|
||||
<div class="column is-clipped is-paddingless">
|
||||
<container-title @close="$emit('close')" />
|
||||
</div>
|
||||
<div class="column is-narrow is-paddingless">
|
||||
<container-stat />
|
||||
</div>
|
||||
|
||||
<div class="mr-2 column is-narrow is-paddingless">
|
||||
<log-actions-toolbar :onClearClicked="onClearClicked" />
|
||||
</div>
|
||||
<div class="mr-2 column is-narrow is-paddingless" v-if="closable">
|
||||
<button class="delete is-medium" @click="emit('close')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
|
||||
|
||||
const {
|
||||
id,
|
||||
showTitle = false,
|
||||
scrollable = false,
|
||||
closable = false,
|
||||
} = defineProps<{
|
||||
id: string;
|
||||
showTitle?: boolean;
|
||||
scrollable?: boolean;
|
||||
closable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const store = useContainerStore();
|
||||
|
||||
const container = store.currentContainer($$(id));
|
||||
|
||||
provide("container", container);
|
||||
|
||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
|
||||
|
||||
function onClearClicked() {
|
||||
viewer.value?.clear();
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
button.delete {
|
||||
background-color: var(--scheme-main-ter);
|
||||
opacity: 0.6;
|
||||
&:after,
|
||||
&:before {
|
||||
background-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
assets/components/LogViewer/LogDate.vue
Normal file
45
assets/components/LogViewer/LogDate.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<relative-time :date="date" class="date"></relative-time>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
date: Date;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.date {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.date {
|
||||
background-color: #262626;
|
||||
color: #258ccd;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
.date {
|
||||
background-color: #262626;
|
||||
color: #258ccd;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.date {
|
||||
background-color: #f0f0f0;
|
||||
color: #009900;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
.date {
|
||||
background-color: #f0f0f0;
|
||||
color: #009900;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
203
assets/components/LogViewer/LogEventSource.spec.ts
Normal file
203
assets/components/LogViewer/LogEventSource.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
// @ts-ignore
|
||||
import EventSource, { sources } from "eventsourcemock";
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { settings } from "../../composables/settings";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { vi, describe, expect, beforeEach, test, afterEach } from "vitest";
|
||||
import { computed, nextTick } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "" },
|
||||
}));
|
||||
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
describe("<LogEventSource />", () => {
|
||||
const search = useSearchFilter();
|
||||
|
||||
beforeEach(() => {
|
||||
global.EventSource = EventSource;
|
||||
// @ts-ignore
|
||||
window.scrollTo = vi.fn();
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1560336942459);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createLogEventSource(
|
||||
{
|
||||
searchFilter = "",
|
||||
hourStyle = "auto",
|
||||
}: { searchFilter?: string | undefined; hourStyle?: "auto" | "24" | "12" } = {
|
||||
hourStyle: "auto",
|
||||
}
|
||||
) {
|
||||
settings.value.hourStyle = hourStyle;
|
||||
search.searchFilter.value = searchFilter;
|
||||
if(searchFilter){
|
||||
search.showSearch.value = true;
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory("/"),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: {
|
||||
template: "Test from createLogEventSource",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return mount(LogEventSource, {
|
||||
global: {
|
||||
plugins: [router, createTestingPinia({ createSpy: vi.fn })],
|
||||
components: {
|
||||
LogViewer,
|
||||
},
|
||||
provide: {
|
||||
container: computed(() => ({ id: "abc", image: "test:v123" })),
|
||||
scrollingPaused: computed(() => false),
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: `
|
||||
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
|
||||
`,
|
||||
},
|
||||
props: {},
|
||||
});
|
||||
}
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should connect to EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("should close EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
wrapper.unmount();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
|
||||
});
|
||||
|
||||
test("should parse messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
// @ts-ignore
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render messages with color", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: '{"ts":1560336942459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}',
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render messages with html entities", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render dates with 12 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render messages with filter", async () => {
|
||||
const wrapper = createLogEventSource({ searchFilter: "test" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"test bar", "id":2}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
25
assets/components/LogViewer/LogEventSource.vue
Normal file
25
assets/components/LogViewer/LogEventSource.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { type ComputedRef } from "vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "loading-more", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
const { messages, loadOlderLogs } = useLogStream(container);
|
||||
|
||||
const beforeLoading = () => emit("loading-more", true);
|
||||
const afterLoading = () => emit("loading-more", false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
|
||||
</script>
|
||||
106
assets/components/LogViewer/LogViewer.vue
Normal file
106
assets/components/LogViewer/LogViewer.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<ul class="events" ref="events" :class="{ 'disable-wrap': !softWrap, [size]: true }">
|
||||
<li
|
||||
v-for="(item, index) in filtered"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
>
|
||||
<div class="line-options" v-show="isSearching()">
|
||||
<dropdown-menu :class="{ 'is-last': index === filtered.length - 1 }" class="is-top minimal">
|
||||
<a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.id}`">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<cil-find-in-page class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</dropdown-menu>
|
||||
</div>
|
||||
<component :is="item.getComponent()" :log-entry="item" :visible-keys="visibleKeys.value"></component>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ComputedRef, toRaw } from "vue";
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
import { Container } from "@/models/Container";
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
}>();
|
||||
|
||||
let visibleKeys = persistentVisibleKeys(inject("container") as ComputedRef<Container>);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages, resetSearch, isSearching } = useSearchFilter();
|
||||
|
||||
const { messages } = toRefs(props);
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
const events = ref<HTMLElement>();
|
||||
let lastSelectedItem: LogEntry<string | JSONObject> | undefined = $ref(undefined);
|
||||
|
||||
function handleJumpLineSelected(e: Event, item: LogEntry<string | JSONObject>) {
|
||||
lastSelectedItem = item;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
const routeHash = useRouteHash();
|
||||
watch(
|
||||
routeHash,
|
||||
(hash) => {
|
||||
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" });
|
||||
},
|
||||
{ immediate: true, flush: "post" }
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.events {
|
||||
padding: 1em 0;
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, monaco, Menlo, monospace;
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
word-wrap: break-word;
|
||||
padding: 0.2em 1em;
|
||||
&:last-child {
|
||||
scroll-snap-align: end;
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
&:nth-child(odd) {
|
||||
background-color: rgba(125, 125, 125, 0.08);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border: 1px var(--secondary-color) solid;
|
||||
}
|
||||
|
||||
& > .line-options {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 120%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
assets/components/LogViewer/LogViewerWithSource.vue
Normal file
21
assets/components/LogViewer/LogViewerWithSource.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<log-event-source ref="source" #default="{ messages }" @loading-more="emit('loading-more', $event)">
|
||||
<log-viewer :messages="messages"></log-viewer>
|
||||
</log-event-source>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "loading-more", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const source = $ref<InstanceType<typeof LogEventSource>>();
|
||||
function clear() {
|
||||
source?.clear();
|
||||
}
|
||||
defineExpose({
|
||||
clear,
|
||||
});
|
||||
</script>
|
||||
32
assets/components/LogViewer/SimpleLogItem.vue
Normal file
32
assets/components/LogViewer/SimpleLogItem.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="columns is-1 is-variable">
|
||||
<div class="column is-narrow" v-if="showTimestamp">
|
||||
<log-date :date="logEntry.date"></log-date>
|
||||
</div>
|
||||
<div class="text column" v-html="colorize(logEntry.message)"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
|
||||
defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
}>();
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.disable-wrap {
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
23
assets/components/LogViewer/SkippedEntriesLogItem.vue
Normal file
23
assets/components/LogViewer/SkippedEntriesLogItem.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="is-flex-grow-1 has-text-centered my-4">
|
||||
<div class="is-relative">
|
||||
<zig-zag class="is-overlay mt-2"></zig-zag>
|
||||
<span class="text is-relative py-2 px-4">{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SkippedLogsEntry } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
logEntry: SkippedLogsEntry;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.text {
|
||||
font-weight: bold;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--body-background-color);
|
||||
}
|
||||
</style>
|
||||
35
assets/components/LogViewer/StatSparkline.vue
Normal file
35
assets/components/LogViewer/StatSparkline.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<svg :width="width" :height="height">
|
||||
<path :d="path" class="area" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { extent } from "d3-array";
|
||||
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[]; width?: number; height?: number }>();
|
||||
const x = d3.scaleLinear().range([0, width]);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const shape = d3
|
||||
.area<Point>()
|
||||
.curve(d3.curveStep)
|
||||
.x((d) => x(d.x))
|
||||
.y0(height)
|
||||
.y1((d) => y(d.y));
|
||||
|
||||
const path = computed(() => {
|
||||
x.domain(d3.extent(data, (d) => d.x) as [number, number]);
|
||||
y.domain(d3.extent(data, (d) => d.y) as [number, number]);
|
||||
return shape(data) ?? "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.area) {
|
||||
fill: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
19
assets/components/LogViewer/ZigZag.vue
Normal file
19
assets/components/LogViewer/ZigZag.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg width="100%" height="8" class="zigzag">
|
||||
<defs>
|
||||
<pattern id="zigzag" x="0" y="0" width="30" height="8" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="15" y2="8" class="line" />
|
||||
<line x1="15" y1="8" x2="30" y2="0" class="line" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#zigzag)"></rect>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line {
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-2e92daca=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div>
|
||||
<div class=\\"text column\\" data-v-a49e52d4=\\"\\"><test>foo bar</test></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-2e92daca=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42</time></div>
|
||||
<div class=\\"text column\\" data-v-a49e52d4=\\"\\"><test>foo bar</test></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-2e92daca=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div>
|
||||
<div class=\\"text column\\" data-v-a49e52d4=\\"\\">This is a message.</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-2e92daca=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div>
|
||||
<div class=\\"text column\\" data-v-a49e52d4=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-2e92daca=\\"\\">
|
||||
<li data-key=\\"2\\" class=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div>
|
||||
<div class=\\"text column\\" data-v-a49e52d4=\\"\\"><mark>test</mark> bar</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-2e92daca=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\">
|
||||
<div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div>
|
||||
<div class=\\"text column\\" data-v-a49e52d4=\\"\\"><test>foo bar</test></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > renders correctly 1`] = `
|
||||
"<div class=\\"infinte-loader\\" data-v-1cd63c6e=\\"\\">
|
||||
<div class=\\"spinner\\" data-v-1cd63c6e=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"bounce1\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
<div class=\\"bounce2\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
<div class=\\"bounce3\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"></ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages 1`] = `
|
||||
SimpleLogEntry {
|
||||
"_message": "This is a message.",
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"id": 1,
|
||||
}
|
||||
`;
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<log-event-source :id="id" v-slot="eventSource" @loading-more="$emit('loading-more', $event)">
|
||||
<log-viewer :messages="eventSource.messages"></log-viewer>
|
||||
</log-event-source>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LogEventSource from "./LogEventSource";
|
||||
import LogViewer from "./LogViewer";
|
||||
|
||||
export default {
|
||||
props: ["id"],
|
||||
name: "LogViewerWithSource",
|
||||
components: {
|
||||
LogEventSource,
|
||||
LogViewer,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -2,14 +2,14 @@
|
||||
<aside>
|
||||
<div class="columns is-marginless is-gapless is-mobile is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<router-link :to="{ name: 'default' }">
|
||||
<router-link :to="{ name: 'index' }">
|
||||
<svg class="logo">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="column ml-4 is-family-monospace is-ellipsis" v-if="$route.name == 'container'">
|
||||
{{ allContainersById[$route.params.id].name }}
|
||||
{{ allContainersById[route.params.id].name }}
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow push-right">
|
||||
@@ -24,11 +24,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">Containers</p>
|
||||
<p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">{{ $t("label.containers") }}</p>
|
||||
<ul class="menu-list is-hidden-mobile" :class="{ 'is-active': showNav }">
|
||||
<li v-for="item in visibleContainers" :key="item.id">
|
||||
<router-link
|
||||
:to="{ name: 'container', params: { id: item.id, name: item.name } }"
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
active-class="is-active"
|
||||
:title="item.name"
|
||||
>
|
||||
@@ -41,32 +41,21 @@
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
<script lang="ts" setup>
|
||||
const store = useContainerStore();
|
||||
const route = useRoute();
|
||||
const { visibleContainers, allContainersById } = storeToRefs(store);
|
||||
|
||||
export default {
|
||||
props: [],
|
||||
name: "MobileMenu",
|
||||
data() {
|
||||
return {
|
||||
showNav: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["visibleContainers", "allContainersById"]),
|
||||
},
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.showNav = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
let showNav = $ref(false);
|
||||
|
||||
watch(route, () => {
|
||||
showNav = false;
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
aside {
|
||||
padding: 1em;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--scheme-main-ter);
|
||||
|
||||
@@ -2,38 +2,18 @@
|
||||
<time :datetime="date.toISOString()">{{ text }}</time>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import formatDistance from "date-fns/formatDistance";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
date: {
|
||||
required: true,
|
||||
type: Date,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: "",
|
||||
interval: null,
|
||||
};
|
||||
},
|
||||
name: "PastTime",
|
||||
mounted() {
|
||||
this.updateFromNow();
|
||||
this.interval = setInterval(() => this.updateFromNow(), 30000);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.interval);
|
||||
},
|
||||
methods: {
|
||||
updateFromNow() {
|
||||
this.text = formatDistance(this.date, new Date(), {
|
||||
addSuffix: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
const { date } = defineProps<{
|
||||
date: Date;
|
||||
}>();
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
const text = ref<string>();
|
||||
function updateFromNow() {
|
||||
text.value = formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
});
|
||||
}
|
||||
useIntervalFn(updateFromNow, 30_000, { immediateCallback: true });
|
||||
</script>
|
||||
|
||||
@@ -1,50 +1,21 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()">{{ date | relativeTime(locale) }}</time>
|
||||
<time :datetime="date.toISOString()">{{ format(date) }}</time>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import { formatRelative } from "date-fns";
|
||||
import enGB from "date-fns/locale/en-GB";
|
||||
import enUS from "date-fns/locale/en-US";
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
date: Date;
|
||||
}>();
|
||||
|
||||
const use24Hr =
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
})
|
||||
.formatToParts(new Date(2020, 0, 1, 13))
|
||||
.find((part) => part.type === "hour").value.length === 2;
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
const use12Hour = $computed(() => ({ auto: undefined, "12": true, "24": false }[hourStyle.value]));
|
||||
const timeFormatter = $computed(
|
||||
() => new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: use12Hour })
|
||||
);
|
||||
|
||||
const auto = use24Hr ? enGB : enUS;
|
||||
const styles = { auto, 12: enUS, 24: enGB };
|
||||
|
||||
export default {
|
||||
props: {
|
||||
date: {
|
||||
required: true,
|
||||
type: Date,
|
||||
},
|
||||
},
|
||||
name: "RelativeTime",
|
||||
components: {},
|
||||
|
||||
computed: {
|
||||
...mapState(["settings"]),
|
||||
locale() {
|
||||
const locale = styles[this.settings.hourStyle];
|
||||
const oldFormatter = locale.formatRelative;
|
||||
return {
|
||||
...locale,
|
||||
formatRelative(token) {
|
||||
return oldFormatter(token) + "p";
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
relativeTime(date, locale) {
|
||||
return formatRelative(date, new Date(), { locale });
|
||||
},
|
||||
},
|
||||
};
|
||||
function format(date: Date) {
|
||||
const dateStr = dateFormatter.format(date);
|
||||
const timeStr = timeFormatter.format(date);
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="scroll-progress">
|
||||
<div class="scroll-progress" ref="root">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" :class="{ indeterminate }">
|
||||
<circle r="44" cx="50" cy="50" :style="{ '--progress': scrollProgress }" />
|
||||
<circle r="44" cx="50" cy="50" />
|
||||
</svg>
|
||||
<div class="is-overlay columns is-vcentered is-centered has-text-weight-light">
|
||||
<template v-if="indeterminate">
|
||||
@@ -17,79 +17,55 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import throttle from "lodash.throttle";
|
||||
<script lang="ts" setup>
|
||||
const { indeterminate = false, autoHide = false } = defineProps<{
|
||||
indeterminate?: boolean;
|
||||
autoHide?: boolean;
|
||||
}>();
|
||||
|
||||
export default {
|
||||
name: "ScrollProgress",
|
||||
props: {
|
||||
indeterminate: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
const scrollProgress = ref(0);
|
||||
const animation = ref({ cancel: () => {} });
|
||||
const root = ref<HTMLElement>();
|
||||
const store = useContainerStore();
|
||||
const { activeContainers } = storeToRefs(store);
|
||||
const scrollElement = ref<HTMLElement | Document>((root.value?.closest("[data-scrolling]") as HTMLElement) ?? document);
|
||||
const { y: scrollY } = useScroll(scrollElement, { throttle: 100 });
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
activeContainers,
|
||||
() => {
|
||||
scrollElement.value = (root.value?.closest("[data-scrolling]") as HTMLElement) ?? document;
|
||||
},
|
||||
autoHide: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrollProgress: 0,
|
||||
animation: { cancel: () => {} },
|
||||
parentElement: document,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.onScrollThrottled = throttle(this.onScroll, 150);
|
||||
},
|
||||
mounted() {
|
||||
this.attachEvents();
|
||||
this.$once("hook:beforeDestroy", this.detachEvents);
|
||||
},
|
||||
watch: {
|
||||
activeContainers() {
|
||||
this.detachEvents();
|
||||
this.attachEvents();
|
||||
},
|
||||
indeterminate() {
|
||||
this.$nextTick(() => this.onScroll());
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["activeContainers"]),
|
||||
},
|
||||
methods: {
|
||||
attachEvents() {
|
||||
this.parentElement = this.$el.closest("[data-scrolling]") || document;
|
||||
this.parentElement.addEventListener("scroll", this.onScrollThrottled);
|
||||
},
|
||||
detachEvents() {
|
||||
this.parentElement.removeEventListener("scroll", this.onScrollThrottled);
|
||||
},
|
||||
onScroll() {
|
||||
const p = this.parentElement == document ? document.documentElement : this.parentElement;
|
||||
this.scrollProgress = p.scrollTop / (p.scrollHeight - p.clientHeight);
|
||||
this.animation.cancel();
|
||||
if (this.autoHide) {
|
||||
this.animation = this.$el.animate(
|
||||
{ opacity: [1, 0] },
|
||||
{
|
||||
duration: 500,
|
||||
delay: 2000,
|
||||
fill: "both",
|
||||
easing: "ease-out",
|
||||
}
|
||||
);
|
||||
{ immediate: true, flush: "post" }
|
||||
);
|
||||
});
|
||||
|
||||
watchPostEffect(() => {
|
||||
const parent =
|
||||
scrollElement.value === document
|
||||
? (scrollElement.value as Document).documentElement
|
||||
: (scrollElement.value as HTMLElement);
|
||||
scrollProgress.value = scrollY.value / (parent.scrollHeight - parent.clientHeight);
|
||||
animation.value.cancel();
|
||||
if (autoHide && root.value) {
|
||||
animation.value = root.value.animate(
|
||||
{ opacity: [1, 0] },
|
||||
{
|
||||
duration: 500,
|
||||
delay: 2000,
|
||||
fill: "both",
|
||||
easing: "ease-out",
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.scroll-progress {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.2));
|
||||
@@ -108,7 +84,7 @@ export default {
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
stroke: var(--primary-color);
|
||||
stroke-dashoffset: calc(276.32px - var(--progress) * 276.32px);
|
||||
stroke-dashoffset: calc(276.32px - v-bind(scrollProgress) * 276.32px);
|
||||
stroke-dasharray: 276.32px 276.32px;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 3;
|
||||
|
||||
@@ -3,82 +3,67 @@
|
||||
<header v-if="$slots.header">
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
<main ref="content" :data-scrolling="scrollable">
|
||||
<main :data-scrolling="scrollable ? true : undefined">
|
||||
<div class="is-scrollbar-progress is-hidden-mobile">
|
||||
<scroll-progress v-show="paused" :indeterminate="loading" :auto-hide="!loading"></scroll-progress>
|
||||
</div>
|
||||
<slot :setLoading="setLoading"></slot>
|
||||
<div ref="scrollableContent">
|
||||
<slot :setLoading="setLoading"></slot>
|
||||
</div>
|
||||
|
||||
<div ref="scrollObserver" class="is-scroll-observer"></div>
|
||||
</main>
|
||||
|
||||
<div class="is-scrollbar-notification">
|
||||
<transition name="fade">
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
|
||||
<icon name="chevrons-down"></icon>
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom()" v-show="paused">
|
||||
<mdi-light-chevron-double-down />
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Icon from "./Icon";
|
||||
import ScrollProgress from "./ScrollProgress";
|
||||
<script lang="ts" setup>
|
||||
const { scrollable = false } = defineProps<{ scrollable?: boolean }>();
|
||||
|
||||
export default {
|
||||
props: {
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Icon,
|
||||
ScrollProgress,
|
||||
},
|
||||
name: "ScrollableView",
|
||||
data() {
|
||||
return {
|
||||
paused: false,
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const { content } = this.$refs;
|
||||
const mutationObserver = new MutationObserver((e) => {
|
||||
if (!this.paused) {
|
||||
this.scrollToBottom("instant");
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (
|
||||
record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]
|
||||
) {
|
||||
this.hasMore = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
mutationObserver.observe(content, { childList: true, subtree: true });
|
||||
this.$once("hook:beforeDestroy", () => mutationObserver.disconnect());
|
||||
let paused = $ref(false);
|
||||
let hasMore = $ref(false);
|
||||
let loading = $ref(false);
|
||||
const scrollObserver = ref<HTMLElement>();
|
||||
const scrollableContent = ref<HTMLElement>();
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => (this.paused = entries[0].intersectionRatio == 0),
|
||||
{ threshholds: [0, 1], rootMargin: "80px 0px" }
|
||||
);
|
||||
intersectionObserver.observe(this.$refs.scrollObserver);
|
||||
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
|
||||
},
|
||||
provide("scrollingPaused", $$(paused));
|
||||
|
||||
methods: {
|
||||
scrollToBottom(behavior = "instant") {
|
||||
this.$refs.scrollObserver.scrollIntoView({ behavior });
|
||||
this.hasMore = false;
|
||||
},
|
||||
setLoading(loading) {
|
||||
this.loading = loading;
|
||||
},
|
||||
},
|
||||
};
|
||||
const mutationObserver = new MutationObserver((e) => {
|
||||
if (!paused) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => (paused = entries[0].intersectionRatio == 0), {
|
||||
threshold: [0, 1],
|
||||
rootMargin: "80px 0px",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
mutationObserver.observe(scrollableContent.value!, { childList: true, subtree: true });
|
||||
intersectionObserver.observe(scrollObserver.value!);
|
||||
});
|
||||
|
||||
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
scrollObserver.value?.scrollIntoView({ behavior });
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
function setLoading(value: boolean) {
|
||||
loading = value;
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
section {
|
||||
@@ -90,6 +75,7 @@ section {
|
||||
top: 0;
|
||||
background: var(--body-background-color);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.is-full-height-scrollable {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="search columns is-gapless is-vcentered" v-show="showSearch" v-if="settings.search">
|
||||
<div class="search columns is-gapless is-vcentered" v-show="showSearch" v-if="search">
|
||||
<div class="column">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Find / RegEx"
|
||||
ref="filter"
|
||||
v-model="filter"
|
||||
ref="input"
|
||||
v-model="searchFilter"
|
||||
@keyup.esc="resetSearch()"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<icon name="search"></icon>
|
||||
<mdi-light-magnify />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -21,57 +21,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "vuex";
|
||||
import hotkeys from "hotkeys-js";
|
||||
import Icon from "./Icon";
|
||||
<script lang="ts" setup>
|
||||
const input = ref<HTMLInputElement>();
|
||||
const { searchFilter, showSearch, resetSearch } = useSearchFilter();
|
||||
|
||||
export default {
|
||||
props: [],
|
||||
name: "Search",
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSearch: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
hotkeys("command+f, ctrl+f", (event, handler) => {
|
||||
this.showSearch = true;
|
||||
this.$nextTick(() => this.$refs.filter.focus() || this.$refs.filter.select());
|
||||
event.preventDefault();
|
||||
});
|
||||
hotkeys("esc", (event, handler) => {
|
||||
this.resetSearch();
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.updateSearchFilter("");
|
||||
hotkeys.unbind("command+f, ctrl+f, esc");
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
updateSearchFilter: "SET_SEARCH",
|
||||
}),
|
||||
resetSearch() {
|
||||
this.showSearch = false;
|
||||
this.filter = "";
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(["searchFilter", "settings"]),
|
||||
filter: {
|
||||
get() {
|
||||
return this.searchFilter;
|
||||
},
|
||||
set(value) {
|
||||
this.updateSearchFilter(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
onKeyStroke("f", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
showSearch.value = true;
|
||||
nextTick(() => input.value?.focus() || input.value?.select());
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => resetSearch());
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2,40 +2,32 @@
|
||||
<aside>
|
||||
<div class="columns is-marginless">
|
||||
<div class="column is-paddingless">
|
||||
<router-link :to="{ name: 'default' }">
|
||||
<router-link :to="{ name: 'index' }">
|
||||
<svg class="logo">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right px-1">
|
||||
<button
|
||||
class="button is-small is-rounded is-settings-control"
|
||||
@click="$emit('search')"
|
||||
title="Search containers (⌘ + k, ⌃k)"
|
||||
>
|
||||
<button class="button is-rounded" @click="$emit('search')" title="$t('tooltip.search')">
|
||||
<span class="icon">
|
||||
<icon name="search"></icon>
|
||||
<mdi-light-magnify />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right px-0">
|
||||
<router-link
|
||||
:to="{ name: 'settings' }"
|
||||
active-class="is-active"
|
||||
class="button is-small is-rounded is-settings-control"
|
||||
>
|
||||
<router-link :to="{ name: 'settings' }" active-class="is-active" class="button is-rounded">
|
||||
<span class="icon">
|
||||
<icon name="cog"></icon>
|
||||
<mdi-light-cog />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<p class="menu-label is-hidden-mobile">Containers</p>
|
||||
<ul class="menu-list is-hidden-mobile">
|
||||
<p class="menu-label is-hidden-mobile">{{ $t("label.containers") }}</p>
|
||||
<ul class="menu-list is-hidden-mobile" v-if="ready">
|
||||
<li v-for="item in visibleContainers" :key="item.id" :class="item.state">
|
||||
<router-link
|
||||
:to="{ name: 'container', params: { id: item.id, name: item.name } }"
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
active-class="is-active"
|
||||
:title="item.name"
|
||||
>
|
||||
@@ -46,49 +38,36 @@
|
||||
<div class="is-flex-shrink-1 column-icon">
|
||||
<span
|
||||
class="icon is-small"
|
||||
@click.stop.prevent="appendActiveContainer(item)"
|
||||
@click.stop.prevent="store.appendActiveContainer(item)"
|
||||
v-show="!activeContainersById[item.id]"
|
||||
title="Pin as column"
|
||||
title="$t('tooltip.pin-column')"
|
||||
>
|
||||
<icon name="column"></icon>
|
||||
<cil-columns />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="menu-list is-hidden-mobile loading" v-else>
|
||||
<li v-for="index in 7" class="my-4"><o-skeleton animated size="large" :key="index"></o-skeleton></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters, mapState } from "vuex";
|
||||
<script lang="ts" setup>
|
||||
import type { Container } from "@/types/Container";
|
||||
|
||||
import Icon from "./Icon";
|
||||
const store = useContainerStore();
|
||||
|
||||
export default {
|
||||
props: [],
|
||||
name: "SideMenu",
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["visibleContainers", "activeContainers"]),
|
||||
activeContainersById() {
|
||||
return this.activeContainers.reduce((map, obj) => {
|
||||
map[obj.id] = obj;
|
||||
return map;
|
||||
}, {});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
appendActiveContainer: "APPEND_ACTIVE_CONTAINER",
|
||||
}),
|
||||
},
|
||||
};
|
||||
const { activeContainers, visibleContainers, ready } = storeToRefs(store);
|
||||
|
||||
const activeContainersById = computed(() =>
|
||||
activeContainers.value.reduce((acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
}, {} as Record<string, Container>)
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
aside {
|
||||
@@ -103,6 +82,10 @@ aside {
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
li.exited a {
|
||||
color: #777;
|
||||
}
|
||||
@@ -116,6 +99,10 @@ li.exited a {
|
||||
.menu-list li {
|
||||
.column-icon {
|
||||
visibility: hidden;
|
||||
|
||||
& > span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .column-icon {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<LogEventSource /> renders correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="infinte-loader"
|
||||
>
|
||||
<div
|
||||
class="spinner"
|
||||
style="display: none;"
|
||||
>
|
||||
<div
|
||||
class="bounce1"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bounce2"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bounce3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="events medium"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { mapGetters } from "vuex";
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters(["allContainersById"]),
|
||||
container() {
|
||||
return this.allContainersById[this.id];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
["container.state"](newValue, oldValue) {
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
this.onContainerStateChange(newValue, oldValue);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onContainerStateChange(newValue, oldValue) {},
|
||||
},
|
||||
};
|
||||
125
assets/composables/eventsource.ts
Normal file
125
assets/composables/eventsource.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { type ComputedRef, type Ref } from "vue";
|
||||
import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
type JSONObject,
|
||||
LogEntry,
|
||||
asLogEntry,
|
||||
DockerEventLogEntry,
|
||||
SkippedLogsEntry,
|
||||
} from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useLogStream(container: ComputedRef<Container>) {
|
||||
let messages: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
let buffer: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>);
|
||||
|
||||
function flushNow() {
|
||||
if (messages.length > config.maxLogs) {
|
||||
if (scrollingPaused) {
|
||||
console.log("Skipping ", buffer.length, " log items");
|
||||
if (messages.at(-1) instanceof SkippedLogsEntry) {
|
||||
const lastEvent = messages.at(-1) as SkippedLogsEntry;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
lastEvent.addSkippedEntries(buffer.length, lastItem);
|
||||
} else {
|
||||
const firstItem = buffer.at(0) as LogEntry<string | JSONObject>;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem));
|
||||
}
|
||||
buffer = [];
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
messages.splice(0, messages.length - config.maxLogs);
|
||||
}
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
}
|
||||
}
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
let es: EventSource | null = null;
|
||||
let lastEventId = "";
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
es?.close();
|
||||
|
||||
if (clear) {
|
||||
flushBuffer.cancel();
|
||||
messages = [];
|
||||
buffer = [];
|
||||
lastEventId = "";
|
||||
}
|
||||
|
||||
es = new EventSource(`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}`);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.push(new DockerEventLogEntry("Container stopped", new Date(), "container-stopped"));
|
||||
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
es.onmessage = (e) => {
|
||||
lastEventId = e.lastEventId;
|
||||
if (e.data) {
|
||||
buffer.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
|
||||
if (messages.length < 300) return;
|
||||
|
||||
beforeLoading();
|
||||
const to = messages[0].date;
|
||||
const last = messages[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${container.value.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.unshift(...newMessages);
|
||||
}
|
||||
afterLoading();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => container.value.state,
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started"));
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => container.value.id,
|
||||
() => connect(),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { ...$$({ messages }), loadOlderLogs };
|
||||
}
|
||||
1
assets/composables/media.ts
Normal file
1
assets/composables/media.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isMobile = useMediaQuery("(max-width: 770px)");
|
||||
82
assets/composables/search.ts
Normal file
82
assets/composables/search.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type Ref } from "vue";
|
||||
import { type LogEntry, type JSONObject, SimpleLogEntry, ComplexLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const searchFilter = ref<string>("");
|
||||
const debouncedSearchFilter = useDebounce(searchFilter);
|
||||
const showSearch = ref(false);
|
||||
|
||||
function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
for (const key in record) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && regex.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value) && matchRecord(value, regex)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useSearchFilter() {
|
||||
const regex = computed(() => {
|
||||
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
|
||||
return isSmartCase ? new RegExp(debouncedSearchFilter.value, "i") : new RegExp(debouncedSearchFilter.value);
|
||||
});
|
||||
|
||||
function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
return computed(() => {
|
||||
if (debouncedSearchFilter.value && showSearch.value) {
|
||||
try {
|
||||
return messages.value.filter((d) => {
|
||||
if (d instanceof SimpleLogEntry) {
|
||||
return regex.value.test(d.message);
|
||||
} else if (d instanceof ComplexLogEntry) {
|
||||
return matchRecord(d.message, regex.value);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
console.info(`Ignoring SyntaxError from search.`, e);
|
||||
return messages.value;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return messages.value;
|
||||
});
|
||||
}
|
||||
|
||||
function markSearch(log: { toString(): string }): string;
|
||||
function markSearch(log: string[]): string[];
|
||||
function markSearch(log: { toString(): string } | string[]) {
|
||||
if (!debouncedSearchFilter.value) {
|
||||
return log;
|
||||
}
|
||||
if (Array.isArray(log)) {
|
||||
return log.map((d) => markSearch(d));
|
||||
}
|
||||
|
||||
return log.toString().replace(regex.value, (match) => `<mark>${match}</mark>`);
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchFilter.value = "";
|
||||
showSearch.value = false;
|
||||
}
|
||||
|
||||
function isSearching() {
|
||||
return showSearch.value && searchFilter.value;
|
||||
}
|
||||
|
||||
return {
|
||||
filteredMessages,
|
||||
searchFilter,
|
||||
showSearch,
|
||||
markSearch,
|
||||
resetSearch,
|
||||
isSearching,
|
||||
};
|
||||
}
|
||||
83
assets/composables/settings.ts
Normal file
83
assets/composables/settings.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS";
|
||||
|
||||
export const DEFAULT_SETTINGS: {
|
||||
search: boolean;
|
||||
size: "small" | "medium" | "large";
|
||||
menuWidth: number;
|
||||
smallerScrollbars: boolean;
|
||||
showTimestamp: boolean;
|
||||
showAllContainers: boolean;
|
||||
lightTheme: "auto" | "dark" | "light";
|
||||
hourStyle: "auto" | "24" | "12";
|
||||
softWrap: boolean;
|
||||
} = {
|
||||
search: true,
|
||||
size: "medium",
|
||||
menuWidth: 15,
|
||||
smallerScrollbars: false,
|
||||
showTimestamp: true,
|
||||
showAllContainers: false,
|
||||
lightTheme: "auto",
|
||||
hourStyle: "auto",
|
||||
softWrap: true,
|
||||
};
|
||||
|
||||
const settings = useStorage(DOZZLE_SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||
settings.value = { ...DEFAULT_SETTINGS, ...settings.value };
|
||||
|
||||
const search = computed({
|
||||
get: () => settings.value.search,
|
||||
set: (value) => (settings.value.search = value),
|
||||
});
|
||||
|
||||
const size = computed({
|
||||
get: () => settings.value.size,
|
||||
set: (value) => (settings.value.size = value),
|
||||
});
|
||||
|
||||
const menuWidth = computed({
|
||||
get: () => settings.value.menuWidth,
|
||||
set: (value) => (settings.value.menuWidth = value),
|
||||
});
|
||||
const smallerScrollbars = computed({
|
||||
get: () => settings.value.smallerScrollbars,
|
||||
set: (value) => (settings.value.smallerScrollbars = value),
|
||||
});
|
||||
|
||||
const showTimestamp = computed({
|
||||
get: () => settings.value.showTimestamp,
|
||||
set: (value) => (settings.value.showTimestamp = value),
|
||||
});
|
||||
|
||||
const showAllContainers = computed({
|
||||
get: () => settings.value.showAllContainers,
|
||||
set: (value) => (settings.value.showAllContainers = value),
|
||||
});
|
||||
|
||||
const lightTheme = computed({
|
||||
get: () => settings.value.lightTheme,
|
||||
set: (value) => (settings.value.lightTheme = value),
|
||||
});
|
||||
|
||||
const hourStyle = computed({
|
||||
get: () => settings.value.hourStyle,
|
||||
set: (value) => (settings.value.hourStyle = value),
|
||||
});
|
||||
|
||||
const softWrap = computed({
|
||||
get: () => settings.value.softWrap,
|
||||
set: (value) => (settings.value.softWrap = value),
|
||||
});
|
||||
|
||||
export {
|
||||
softWrap,
|
||||
hourStyle,
|
||||
lightTheme,
|
||||
showAllContainers,
|
||||
showTimestamp,
|
||||
smallerScrollbars,
|
||||
menuWidth,
|
||||
size,
|
||||
search,
|
||||
settings
|
||||
};
|
||||
8
assets/composables/title.ts
Normal file
8
assets/composables/title.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
let subtitle = $ref("");
|
||||
const title = $computed(() => `${subtitle} - Dozzle`);
|
||||
|
||||
useTitle($$(title));
|
||||
|
||||
export function setTitle(t: string) {
|
||||
subtitle = t;
|
||||
}
|
||||
18
assets/composables/visible.ts
Normal file
18
assets/composables/visible.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: ComputedRef<Ref<string[][]>>) {
|
||||
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
return computed(() => {
|
||||
return messages.value.map((d) => {
|
||||
if (d instanceof ComplexLogEntry) {
|
||||
return ComplexLogEntry.fromLogEvent(d, visibleKeys.value);
|
||||
} else {
|
||||
return d;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { filteredPayload };
|
||||
}
|
||||
122
assets/layouts/default.vue
Normal file
122
assets/layouts/default.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<main v-if="!authorizationNeeded">
|
||||
<mobile-menu v-if="isMobile"></mobile-menu>
|
||||
<splitpanes @resized="onResized($event)">
|
||||
<pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav">
|
||||
<side-menu @search="showFuzzySearch"></side-menu>
|
||||
</pane>
|
||||
<pane min-size="10">
|
||||
<splitpanes>
|
||||
<pane class="has-min-height router-view">
|
||||
<router-view></router-view>
|
||||
</pane>
|
||||
<template v-if="!isMobile">
|
||||
<pane v-for="other in activeContainers" :key="other.id">
|
||||
<log-container
|
||||
:id="other.id"
|
||||
show-title
|
||||
scrollable
|
||||
closable
|
||||
@close="containerStore.removeActiveContainer(other)"
|
||||
></log-container>
|
||||
</pane>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</pane>
|
||||
</splitpanes>
|
||||
<button
|
||||
@click="collapseNav = !collapseNav"
|
||||
class="button is-rounded"
|
||||
:class="{ collapsed: collapseNav }"
|
||||
id="hide-nav"
|
||||
v-if="!isMobile"
|
||||
>
|
||||
<span class="icon ml-2" v-if="collapseNav">
|
||||
<mdi-light-chevron-right />
|
||||
</span>
|
||||
<span class="icon" v-else>
|
||||
<mdi-light-chevron-left />
|
||||
</span>
|
||||
</button>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// @ts-ignore - splitpanes types are not available
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
import FuzzySearchModal from "@/components/FuzzySearchModal.vue";
|
||||
|
||||
const collapseNav = ref(false);
|
||||
const { oruga } = useProgrammatic();
|
||||
const { authorizationNeeded } = config;
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { activeContainers, visibleContainers } = storeToRefs(containerStore);
|
||||
|
||||
watchEffect(() => {
|
||||
setTitle(`${visibleContainers.value.length} containers`);
|
||||
});
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
showFuzzySearch();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
function showFuzzySearch() {
|
||||
oruga.modal.open({
|
||||
// parent: this,
|
||||
component: FuzzySearchModal,
|
||||
animation: "false",
|
||||
width: 600,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
function onResized(e: any) {
|
||||
if (e.length == 2) {
|
||||
menuWidth.value = e[0].size;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
min-width: 3px;
|
||||
background: var(--border-color);
|
||||
&:hover {
|
||||
background: var(--border-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
}
|
||||
}
|
||||
|
||||
.button.has-no-border {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.has-min-height {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#hide-nav {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
&.collapsed {
|
||||
left: -40px;
|
||||
width: 60px;
|
||||
padding-left: 40px;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
|
||||
&:hover {
|
||||
left: -25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
assets/layouts/splash.vue
Normal file
9
assets/layouts/splash.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<main>
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,72 +0,0 @@
|
||||
import Vue from "vue";
|
||||
import VueRouter from "vue-router";
|
||||
import Meta from "vue-meta";
|
||||
import Switch from "buefy/dist/esm/switch";
|
||||
import Radio from "buefy/dist/esm/radio";
|
||||
import Field from "buefy/dist/esm/field";
|
||||
import Modal from "buefy/dist/esm/modal";
|
||||
import Autocomplete from "buefy/dist/esm/autocomplete";
|
||||
|
||||
import store from "./store";
|
||||
import config from "./store/config";
|
||||
import App from "./App.vue";
|
||||
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages";
|
||||
|
||||
Vue.use(VueRouter);
|
||||
Vue.use(Meta);
|
||||
Vue.use(Switch);
|
||||
Vue.use(Radio);
|
||||
Vue.use(Field);
|
||||
Vue.use(Modal);
|
||||
Vue.use(Autocomplete);
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
component: Index,
|
||||
name: "default",
|
||||
},
|
||||
{
|
||||
path: "/container/:id",
|
||||
component: Container,
|
||||
name: "container",
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/container/*",
|
||||
component: ContainerNotFound,
|
||||
name: "container-not-found",
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
component: Settings,
|
||||
name: "settings",
|
||||
},
|
||||
{
|
||||
path: "/show",
|
||||
component: Show,
|
||||
name: "show",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: Login,
|
||||
name: "login",
|
||||
},
|
||||
{
|
||||
path: "/*",
|
||||
component: PageNotFound,
|
||||
name: "page-not-found",
|
||||
},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: "history",
|
||||
base: config.base + "/",
|
||||
routes,
|
||||
});
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app");
|
||||
10
assets/main.ts
Normal file
10
assets/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import "./styles.scss";
|
||||
import { createApp, App as VueApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
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");
|
||||
31
assets/models/Container.ts
Normal file
31
assets/models/Container.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ContainerStat, ContainerState } from "@/types/Container";
|
||||
import type { UseThrottledRefHistoryReturn } from "@vueuse/core";
|
||||
import { Ref } from "vue";
|
||||
|
||||
type Stat = Omit<ContainerStat, "id">;
|
||||
|
||||
export class Container {
|
||||
public stat: Ref<Stat>;
|
||||
private readonly throttledStatHistory: UseThrottledRefHistoryReturn<Stat, Stat>;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly created: number,
|
||||
public readonly image: string,
|
||||
public readonly name: string,
|
||||
public readonly command: string,
|
||||
public status: string,
|
||||
public state: ContainerState
|
||||
) {
|
||||
this.stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
this.throttledStatHistory = useThrottledRefHistory(this.stat, { capacity: 300, deep: true, throttle: 1000 });
|
||||
}
|
||||
|
||||
public getStatHistory() {
|
||||
return unref(this.throttledStatHistory.history);
|
||||
}
|
||||
|
||||
public getLastStat() {
|
||||
return unref(this.throttledStatHistory.last);
|
||||
}
|
||||
}
|
||||
121
assets/models/LogEntry.ts
Normal file
121
assets/models/LogEntry.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Component, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON, getDeep } from "@/utils";
|
||||
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
|
||||
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
|
||||
import DockerEventLogItem from "@/components/LogViewer/DockerEventLogItem.vue";
|
||||
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
|
||||
|
||||
export interface HasComponent {
|
||||
getComponent(): Component;
|
||||
}
|
||||
|
||||
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
|
||||
export type JSONObject = { [x: string]: JSONValue };
|
||||
|
||||
export interface LogEvent {
|
||||
readonly m: string | JSONObject;
|
||||
readonly ts: number;
|
||||
readonly id: number;
|
||||
}
|
||||
|
||||
export abstract class LogEntry<T extends string | JSONObject> implements HasComponent {
|
||||
protected readonly _message: T;
|
||||
constructor(message: T, public readonly id: number, public readonly date: Date) {
|
||||
this._message = message;
|
||||
}
|
||||
|
||||
public get message(): T {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
abstract getComponent(): Component;
|
||||
}
|
||||
|
||||
export class SimpleLogEntry extends LogEntry<string> {
|
||||
getComponent(): Component {
|
||||
return SimpleLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
private readonly filteredMessage: ComputedRef<JSONObject>;
|
||||
|
||||
constructor(message: JSONObject, id: number, date: Date, visibleKeys?: Ref<string[][]>) {
|
||||
super(message, id, date);
|
||||
if (visibleKeys) {
|
||||
this.filteredMessage = computed(() => {
|
||||
if (!visibleKeys.value.length) {
|
||||
return flattenJSON(message);
|
||||
} else {
|
||||
return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.filteredMessage = computed(() => flattenJSON(message));
|
||||
}
|
||||
}
|
||||
getComponent(): Component {
|
||||
return ComplexLogItem;
|
||||
}
|
||||
|
||||
public get message(): JSONObject {
|
||||
return this.filteredMessage.value;
|
||||
}
|
||||
|
||||
public get unfilteredMessage(): JSONObject {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry {
|
||||
return new ComplexLogEntry(event._message, event.id, event.date, visibleKeys);
|
||||
}
|
||||
}
|
||||
|
||||
export class DockerEventLogEntry extends LogEntry<string> {
|
||||
constructor(message: string, date: Date, public readonly event: string) {
|
||||
super(message, date.getTime(), date);
|
||||
}
|
||||
getComponent(): Component {
|
||||
return DockerEventLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class SkippedLogsEntry extends LogEntry<string> {
|
||||
private _totalSkipped = 0;
|
||||
private lastSkipped: LogEntry<string | JSONObject>;
|
||||
|
||||
constructor(
|
||||
date: Date,
|
||||
totalSkipped: number,
|
||||
public readonly firstSkipped: LogEntry<string | JSONObject>,
|
||||
lastSkipped: LogEntry<string | JSONObject>
|
||||
) {
|
||||
super("", date.getTime(), date);
|
||||
this._totalSkipped = totalSkipped;
|
||||
this.lastSkipped = lastSkipped;
|
||||
}
|
||||
getComponent(): Component {
|
||||
return SkippedEntriesLogItem;
|
||||
}
|
||||
|
||||
public get message(): string {
|
||||
return `Skipped ${this.totalSkipped} entries`;
|
||||
}
|
||||
|
||||
public addSkippedEntries(totalSkipped: number, lastItem: LogEntry<string | JSONObject>) {
|
||||
this._totalSkipped += totalSkipped;
|
||||
this.lastSkipped = lastItem;
|
||||
}
|
||||
|
||||
public get totalSkipped(): number {
|
||||
return this._totalSkipped;
|
||||
}
|
||||
}
|
||||
|
||||
export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
|
||||
if (typeof event.m === "string") {
|
||||
return new SimpleLogEntry(event.m, event.id, new Date(event.ts));
|
||||
} else {
|
||||
return new ComplexLogEntry(event.m, event.id, new Date(event.ts));
|
||||
}
|
||||
}
|
||||
28
assets/modules/bulma.ts
Normal file
28
assets/modules/bulma.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { type App } from "vue";
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
Dropdown,
|
||||
Switch,
|
||||
Radio,
|
||||
Skeleton,
|
||||
Field,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Config,
|
||||
} from "@oruga-ui/oruga-next";
|
||||
import { bulmaConfig } from "@oruga-ui/theme-bulma";
|
||||
|
||||
export const install = (app: App) => {
|
||||
app
|
||||
.use(Autocomplete)
|
||||
.use(Button)
|
||||
.use(Dropdown)
|
||||
.use(Switch)
|
||||
.use(Tooltip)
|
||||
.use(Modal)
|
||||
.use(Radio)
|
||||
.use(Field)
|
||||
.use(Skeleton)
|
||||
.use(Config, bulmaConfig);
|
||||
};
|
||||
20
assets/modules/i18n.ts
Normal file
20
assets/modules/i18n.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
export const install = (app: App) => {
|
||||
const messages = Object.fromEntries(
|
||||
Object.entries(import.meta.glob<{ default: any }>("../../locales/*.y(a)?ml", { eager: true })).map(
|
||||
([key, value]) => {
|
||||
const yaml = key.endsWith(".yaml");
|
||||
return [key.slice(14, yaml ? -5 : -4), value.default];
|
||||
}
|
||||
)
|
||||
);
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: navigator.language.slice(0, 2),
|
||||
fallbackLocale: "en",
|
||||
messages,
|
||||
});
|
||||
app.use(i18n);
|
||||
};
|
||||
8
assets/modules/pinia.ts
Normal file
8
assets/modules/pinia.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type App } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
|
||||
export const install = (app:App) => {
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
};
|
||||
16
assets/modules/router.ts
Normal file
16
assets/modules/router.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type App } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import pages from "~pages";
|
||||
import { setupLayouts } from "virtual:generated-layouts";
|
||||
import config from "@/stores/config";
|
||||
|
||||
export const install = (app: App) => {
|
||||
const routes = setupLayouts(pages);
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(`${config.base}/`),
|
||||
routes,
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<search></search>
|
||||
<log-container :id="id" show-title :scrollable="activeContainers.length > 0"> </log-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import Search from "../components/Search";
|
||||
import LogContainer from "../components/LogContainer";
|
||||
|
||||
export default {
|
||||
props: ["id"],
|
||||
name: "Container",
|
||||
components: {
|
||||
LogContainer,
|
||||
Search,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
title: "loading",
|
||||
};
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.title,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (this.allContainersById[this.id]) {
|
||||
this.title = this.allContainersById[this.id].name;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["allContainersById", "activeContainers"]),
|
||||
},
|
||||
watch: {
|
||||
id() {
|
||||
this.title = this.allContainersById[this.id].name;
|
||||
},
|
||||
allContainersById() {
|
||||
this.title = this.allContainersById[this.id].name;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="hero is-halfheight">
|
||||
<div class="hero-body">
|
||||
<div class="container has-text-centered">
|
||||
<h1 class="title">
|
||||
Container not found.
|
||||
<small class="subtitle">It may have been removed.</small>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContainerNotFound",
|
||||
metaInfo() {
|
||||
return {
|
||||
title: "Not Found",
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -4,33 +4,42 @@
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h1 class="title">Hello, there!</h1>
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="secured">
|
||||
<a class="button is-primary is-small" :href="`${base}/logout`">Logout</a>
|
||||
<a class="button is-primary is-small" :href="`${base}/logout`">{{ $t("button.logout") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="level section is-mobile">
|
||||
<section class="level section">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">{{ containers.length }}</p>
|
||||
<p class="heading">Total Containers</p>
|
||||
<p class="heading">{{ $t("label.total-containers") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">{{ runningContainers.length }}</p>
|
||||
<p class="heading">Running</p>
|
||||
<p class="heading">{{ $t("label.running") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title" data-ci-skip>{{ totalCpu }}%</p>
|
||||
<p class="heading">{{ $t("label.total-cpu-usage") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title" data-ci-skip>{{ formatBytes(totalMem) }}</p>
|
||||
<p class="heading">{{ $t("label.total-mem-usage") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">{{ version }}</p>
|
||||
<p class="heading">Dozzle Version</p>
|
||||
<p class="heading">{{ $t("label.dozzle-version") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -38,29 +47,29 @@
|
||||
<section class="columns is-centered section is-marginless">
|
||||
<div class="column is-4">
|
||||
<div class="panel">
|
||||
<p class="panel-heading">Containers</p>
|
||||
<p class="panel-heading">{{ $t("label.containers") }}</p>
|
||||
<div class="panel-block">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Search Containers"
|
||||
v-model="search"
|
||||
@keyup.esc="search = null"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
v-model="query"
|
||||
@keyup.esc="query = ''"
|
||||
@keyup.enter="onEnter()"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<icon name="search"></icon>
|
||||
<search-icon />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="panel-tabs" v-if="!search">
|
||||
<a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">Running</a>
|
||||
<a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">All</a>
|
||||
<p class="panel-tabs" v-if="query === ''">
|
||||
<a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">{{ $t("label.running") }}</a>
|
||||
<a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">{{ $t("label.all") }}</a>
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'container', params: { id: item.id, name: item.name } }"
|
||||
v-for="item in results.slice(0, 10)"
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
v-for="item in data.slice(0, 10)"
|
||||
:key="item.id"
|
||||
class="panel-block"
|
||||
>
|
||||
@@ -76,60 +85,63 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import Icon from "../components/Icon";
|
||||
import PastTime from "../components/PastTime";
|
||||
import config from "../store/config";
|
||||
import fuzzysort from "fuzzysort";
|
||||
<script lang="ts" setup>
|
||||
import SearchIcon from "~icons/mdi-light/magnify";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
|
||||
export default {
|
||||
name: "Index",
|
||||
components: { Icon, PastTime },
|
||||
data() {
|
||||
return {
|
||||
version: config.version,
|
||||
search: null,
|
||||
sort: "running",
|
||||
secured: config.secured,
|
||||
base: config.base,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onEnter() {
|
||||
if (this.results.length == 1) {
|
||||
const [item] = this.results;
|
||||
this.$router.push({ name: "container", params: { id: item.id, name: item.name } });
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(["containers"]),
|
||||
mostRecentContainers() {
|
||||
return [...this.containers].sort((a, b) => b.created - a.created);
|
||||
},
|
||||
runningContainers() {
|
||||
return this.mostRecentContainers.filter((c) => c.state === "running");
|
||||
},
|
||||
allContainers() {
|
||||
return this.containers;
|
||||
},
|
||||
results() {
|
||||
if (this.search) {
|
||||
return fuzzysort.go(this.search, this.allContainers, { key: "name" }).map((i) => i.obj);
|
||||
}
|
||||
switch (this.sort) {
|
||||
case "all":
|
||||
return this.mostRecentContainers;
|
||||
case "running":
|
||||
return this.runningContainers;
|
||||
const { base, version, secured } = config;
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore);
|
||||
const router = useRouter();
|
||||
|
||||
default:
|
||||
throw `Invalid sort order: ${this.sort}`;
|
||||
}
|
||||
},
|
||||
const sort = $ref("running");
|
||||
const query = ref("");
|
||||
|
||||
const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => b.created - a.created));
|
||||
const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running"));
|
||||
|
||||
const { results } = useFuse(query, containers, {
|
||||
fuseOptions: { keys: ["name"] },
|
||||
matchAllWhenSearchEmpty: false,
|
||||
});
|
||||
const data = computed(() => {
|
||||
if (results.value.length) {
|
||||
return results.value.map(({ item }) => item);
|
||||
}
|
||||
switch (sort) {
|
||||
case "all":
|
||||
return mostRecentContainers;
|
||||
case "running":
|
||||
return runningContainers;
|
||||
default:
|
||||
throw `Invalid sort order: ${sort}`;
|
||||
}
|
||||
});
|
||||
|
||||
let totalCpu = $ref(0);
|
||||
useIntervalFn(
|
||||
() => {
|
||||
totalCpu = runningContainers.reduce((acc, c) => acc + (c.stat?.cpu ?? 0), 0);
|
||||
},
|
||||
};
|
||||
1000,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
let totalMem = $ref(0);
|
||||
useIntervalFn(
|
||||
() => {
|
||||
totalMem = runningContainers.reduce((acc, c) => acc + (c.stat?.memoryUsage ?? 0), 0);
|
||||
},
|
||||
1000,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function onEnter() {
|
||||
if (data.value.length > 0) {
|
||||
const item = data.value[0];
|
||||
router.push({ name: "container-id", params: { id: item.id } });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="card-content">
|
||||
<form action="" method="post" @submit.prevent="onLogin" ref="form">
|
||||
<div class="field">
|
||||
<label class="label">Username</label>
|
||||
<label class="label">{{ $t("label.username") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<label class="label">{{ $t("label.password") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
@@ -32,11 +32,11 @@
|
||||
v-model="password"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="error">Username and password are not valid.</p>
|
||||
<p class="help is-danger" v-if="error">{{ $t("error.invalid-auth") }}</p>
|
||||
</div>
|
||||
<div class="field is-grouped is-grouped-centered mt-5">
|
||||
<p class="control">
|
||||
<button class="button is-primary" type="submit">Login</button>
|
||||
<button class="button is-primary" type="submit">{{ $t("button.login") }}</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
@@ -49,36 +49,31 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import config from "../store/config";
|
||||
export default {
|
||||
name: "Login",
|
||||
data() {
|
||||
return {
|
||||
username: null,
|
||||
password: null,
|
||||
error: false,
|
||||
};
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: "Authentication Required",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async onLogin() {
|
||||
const response = await fetch(`${config.base}/api/validateCredentials`, {
|
||||
body: new FormData(this.$refs.form),
|
||||
method: "post",
|
||||
});
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
|
||||
if (response.status == 200) {
|
||||
this.error = false;
|
||||
window.location.href = `${config.base}/`;
|
||||
} else {
|
||||
this.error = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
setTitle(t("title.login"));
|
||||
|
||||
let error = $ref(false);
|
||||
let username = $ref("");
|
||||
let password = $ref("");
|
||||
let form: HTMLFormElement = $ref();
|
||||
|
||||
async function onLogin() {
|
||||
const response = await fetch(`${config.base}/api/validateCredentials`, {
|
||||
body: new FormData(form),
|
||||
method: "post",
|
||||
});
|
||||
|
||||
if (response.status == 200) {
|
||||
error = false;
|
||||
window.location.href = `${config.base}/`;
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: splash
|
||||
</route>
|
||||
|
||||
@@ -2,149 +2,173 @@
|
||||
<div>
|
||||
<section class="section">
|
||||
<div class="has-underline">
|
||||
<h2 class="title is-4">About</h2>
|
||||
<h2 class="title is-4">{{ $t("settings.about") }}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
You are using Dozzle <i>{{ currentVersion }}</i
|
||||
>.
|
||||
<span v-if="hasUpdate">
|
||||
New version is available! Update to
|
||||
<a :href="nextRelease.html_url" class="next-release" target="_blank" rel="noreferrer noopener">{{
|
||||
nextRelease.name
|
||||
}}</a
|
||||
>.
|
||||
</span>
|
||||
<span v-html="$t('settings.using-version', { version: currentVersion })"></span>
|
||||
<div
|
||||
v-if="hasUpdate"
|
||||
v-html="$t('settings.update-available', { nextVersion: nextRelease.name, href: nextRelease.html_url })"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="has-underline">
|
||||
<h2 class="title is-4">Display</h2>
|
||||
<h2 class="title is-4">{{ $t("settings.display") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<o-switch v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </o-switch>
|
||||
</div>
|
||||
<div class="item">
|
||||
<o-switch v-model="showTimestamp"> {{ $t("settings.show-timesamps") }} </o-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<o-switch v-model="softWrap"> {{ $t("settings.soft-wrap") }}</o-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<b-field>
|
||||
<b-radio-button
|
||||
v-model="hourStyle"
|
||||
:native-value="value"
|
||||
v-for="value in ['auto', '12', '24']"
|
||||
:key="value"
|
||||
>
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</b-radio-button>
|
||||
</b-field>
|
||||
<o-field>
|
||||
<o-dropdown v-model="hourStyle" aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary" type="button">
|
||||
<span class="is-capitalized">{{ hourStyle }}</span>
|
||||
<span class="icon">
|
||||
<carbon-caret-down />
|
||||
</span>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item :value="value" aria-role="listitem" v-for="value in ['auto', '12', '24']" :key="value">
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
By default, Dozzle will use your browser's locale to format time. You can force to 12 or 24 hour style.
|
||||
{{ $t("settings.12-24-format") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<b-switch v-model="smallerScrollbars"> Use smaller scrollbars </b-switch>
|
||||
</div>
|
||||
<div class="item">
|
||||
<b-switch v-model="showTimestamp"> Show timestamps </b-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<b-field>
|
||||
<b-radio-button
|
||||
v-model="size"
|
||||
:native-value="value"
|
||||
v-for="value in ['small', 'medium', 'large']"
|
||||
:key="value"
|
||||
>
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</b-radio-button>
|
||||
</b-field>
|
||||
<o-field>
|
||||
<o-dropdown v-model="size" aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary" type="button">
|
||||
<span class="is-capitalized">{{ size }}</span>
|
||||
<span class="icon">
|
||||
<carbon-caret-down />
|
||||
</span>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="value"
|
||||
aria-role="listitem"
|
||||
v-for="value in ['small', 'medium', 'large']"
|
||||
:key="value"
|
||||
>
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="column">Font size to use for logs</div>
|
||||
<div class="column">{{ $t("settings.font-size") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<o-field>
|
||||
<o-dropdown v-model="lightTheme" aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary" type="button">
|
||||
<span class="is-capitalized">{{ lightTheme }}</span>
|
||||
<span class="icon">
|
||||
<carbon-caret-down />
|
||||
</span>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="value"
|
||||
aria-role="listitem"
|
||||
v-for="value in ['auto', 'dark', 'light']"
|
||||
:key="value"
|
||||
>
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="column">{{ $t("settings.color-scheme") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="has-underline">
|
||||
<h2 class="title is-4">Options</h2>
|
||||
<h2 class="title is-4">{{ $t("settings.options") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<b-switch v-model="search">
|
||||
Enable searching with Dozzle using <code>command+f</code> or <code>ctrl+f</code>
|
||||
</b-switch>
|
||||
<o-switch v-model="search">
|
||||
<span v-html="$t('settings.search')"></span>
|
||||
</o-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<b-switch v-model="showAllContainers"> Show stopped containers </b-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<b-switch v-model="lightTheme"> Use light theme </b-switch>
|
||||
<o-switch v-model="showAllContainers"> {{ $t("settings.show-stopped-containers") }} </o-switch>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import gt from "semver/functions/gt";
|
||||
import { mapActions, mapState } from "vuex";
|
||||
import Icon from "../components/Icon";
|
||||
import config from "../store/config";
|
||||
import {
|
||||
search,
|
||||
lightTheme,
|
||||
smallerScrollbars,
|
||||
showTimestamp,
|
||||
hourStyle,
|
||||
showAllContainers,
|
||||
size,
|
||||
softWrap,
|
||||
} from "@/composables/settings";
|
||||
|
||||
export default {
|
||||
props: [],
|
||||
name: "Settings",
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentVersion: config.version,
|
||||
nextRelease: null,
|
||||
hasUpdate: false,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
const releases = await (await fetch("https://api.github.com/repos/amir20/dozzle/releases")).json();
|
||||
if (this.currentVersion !== "master") {
|
||||
this.hasUpdate = gt(releases[0].tag_name, this.currentVersion);
|
||||
} else {
|
||||
this.hasUpdate = true;
|
||||
const { t } = useI18n();
|
||||
|
||||
setTitle(t("title.settings"));
|
||||
|
||||
const currentVersion = $ref(config.version);
|
||||
let nextRelease = $ref({ html_url: "", name: "" });
|
||||
let hasUpdate = $ref(false);
|
||||
|
||||
async function fetchNextRelease() {
|
||||
if (!["dev", "master"].includes(currentVersion)) {
|
||||
const response = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
|
||||
if (response.ok) {
|
||||
const release = await response.json();
|
||||
hasUpdate = gt(release.tag_name, currentVersion);
|
||||
nextRelease = release;
|
||||
}
|
||||
this.nextRelease = releases[0];
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: "Settings",
|
||||
} else {
|
||||
hasUpdate = true;
|
||||
nextRelease = {
|
||||
html_url: "",
|
||||
name: "master",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
updateSetting: "UPDATE_SETTING",
|
||||
}),
|
||||
},
|
||||
computed: {
|
||||
...mapState(["settings"]),
|
||||
...["search", "size", "smallerScrollbars", "showTimestamp", "showAllContainers", "lightTheme", "hourStyle"].reduce(
|
||||
(map, name) => {
|
||||
map[name] = {
|
||||
get() {
|
||||
return this.settings[name];
|
||||
},
|
||||
set(value) {
|
||||
this.updateSetting({ [name]: value });
|
||||
},
|
||||
};
|
||||
return map;
|
||||
},
|
||||
{}
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fetchNextRelease();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
<template></template>
|
||||
<script lang="ts" setup>
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters, mapState } from "vuex";
|
||||
export default {
|
||||
props: [],
|
||||
name: "Show",
|
||||
computed: mapGetters(["visibleContainers"]),
|
||||
watch: {
|
||||
visibleContainers(newValue) {
|
||||
if (newValue) {
|
||||
if (this.$route.query.name) {
|
||||
const [container, _] = this.visibleContainers.filter((c) => c.name == this.$route.query.name);
|
||||
if (container) {
|
||||
this.$router.push({ name: "container", params: { id: container.id } });
|
||||
} else {
|
||||
console.error(`No containers found matching name=${this.$route.query.name}. Redirecting to /`);
|
||||
this.$router.push({ name: "default" });
|
||||
}
|
||||
} else {
|
||||
console.error(`Expection query parameter name to be set. Redirecting to /`);
|
||||
this.$router.push({ name: "default" });
|
||||
}
|
||||
const store = useContainerStore();
|
||||
const { visibleContainers } = storeToRefs(store);
|
||||
|
||||
watch(visibleContainers, (newValue) => {
|
||||
if (newValue) {
|
||||
if (route.query.name) {
|
||||
const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name);
|
||||
if (container) {
|
||||
router.push({ name: "container-id", params: { id: container.id } });
|
||||
} else {
|
||||
console.error(`No containers found matching name=${route.query.name}. Redirecting to /`);
|
||||
router.push({ name: "index" });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
console.error(`Expection query parameter name to be set. Redirecting to /`);
|
||||
router.push({ name: "index" });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped></style>
|
||||
<template></template>
|
||||
|
||||
@@ -3,21 +3,15 @@
|
||||
<div class="hero-body">
|
||||
<div class="container has-text-centered">
|
||||
<h1 class="title">
|
||||
Oops,
|
||||
<small class="subtitle">this page doesn't exist</small>
|
||||
404.
|
||||
<small class="subtitle">{{ $t("error.page-not-found") }}</small>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "PageNotFound",
|
||||
metaInfo() {
|
||||
return {
|
||||
title: "404 Error",
|
||||
};
|
||||
},
|
||||
};
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
setTitle(t("title.page-not-found"));
|
||||
</script>
|
||||
20
assets/pages/container/[id].vue
Normal file
20
assets/pages/container/[id].vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<search></search>
|
||||
<log-container :id="id" show-title :scrollable="activeContainers.length > 0"> </log-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const store = useContainerStore();
|
||||
const { id } = defineProps<{ id: string }>();
|
||||
|
||||
const currentContainer = store.currentContainer($$(id));
|
||||
const { activeContainers } = storeToRefs(store);
|
||||
|
||||
setTitle("loading");
|
||||
|
||||
onMounted(() => {
|
||||
setTitle(currentContainer.value?.name);
|
||||
});
|
||||
|
||||
watchEffect(() => setTitle(currentContainer.value?.name));
|
||||
</script>
|
||||
@@ -1,7 +0,0 @@
|
||||
export { default as Index } from "./Index.vue";
|
||||
export { default as ContainerNotFound } from "./ContainerNotFound.vue";
|
||||
export { default as Show } from "./Show.vue";
|
||||
export { default as Container } from "./Container.vue";
|
||||
export { default as Settings } from "./Settings.vue";
|
||||
export { default as PageNotFound } from "./PageNotFound.vue";
|
||||
export { default as Login } from "./Login.vue";
|
||||
170
assets/pages/index.vue
Normal file
170
assets/pages/index.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="hero is-small mt-4">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="columns">
|
||||
<div class="column is-narrow" v-if="secured">
|
||||
<a class="button is-primary is-small" :href="`${base}/logout`">{{ $t("button.logout") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="level section">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">{{ containers.length }}</p>
|
||||
<p class="heading">{{ $t("label.total-containers") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">{{ runningContainers.length }}</p>
|
||||
<p class="heading">{{ $t("label.running") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title" data-ci-skip>{{ totalCpu }}%</p>
|
||||
<p class="heading">{{ $t("label.total-cpu-usage") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title" data-ci-skip>{{ formatBytes(totalMem) }}</p>
|
||||
<p class="heading">{{ $t("label.total-mem-usage") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">{{ version }}</p>
|
||||
<p class="heading">{{ $t("label.dozzle-version") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="columns is-centered section is-marginless">
|
||||
<div class="column is-4">
|
||||
<div class="panel">
|
||||
<p class="panel-heading">{{ $t("label.containers") }}</p>
|
||||
<div class="panel-block">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
v-model="query"
|
||||
@keyup.esc="query = ''"
|
||||
@keyup.enter="onEnter()"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<search-icon />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="panel-tabs" v-if="query === ''">
|
||||
<a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">{{ $t("label.running") }}</a>
|
||||
<a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">{{ $t("label.all") }}</a>
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
v-for="item in data.slice(0, 10)"
|
||||
:key="item.id"
|
||||
class="panel-block"
|
||||
>
|
||||
<span class="name">{{ item.name }}</span>
|
||||
|
||||
<div class="subtitle is-7 status">
|
||||
<past-time :date="new Date(item.created * 1000)"></past-time>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SearchIcon from "~icons/mdi-light/magnify";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
|
||||
const { base, version, secured } = config;
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore);
|
||||
const router = useRouter();
|
||||
|
||||
const sort = $ref("running");
|
||||
const query = ref("");
|
||||
|
||||
const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => b.created - a.created));
|
||||
const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running"));
|
||||
|
||||
const { results } = useFuse(query, containers, {
|
||||
fuseOptions: { keys: ["name"] },
|
||||
matchAllWhenSearchEmpty: false,
|
||||
});
|
||||
const data = computed(() => {
|
||||
if (results.value.length) {
|
||||
return results.value.map(({ item }) => item);
|
||||
}
|
||||
switch (sort) {
|
||||
case "all":
|
||||
return mostRecentContainers;
|
||||
case "running":
|
||||
return runningContainers;
|
||||
default:
|
||||
throw `Invalid sort order: ${sort}`;
|
||||
}
|
||||
});
|
||||
|
||||
let totalCpu = $ref(0);
|
||||
useIntervalFn(
|
||||
() => {
|
||||
totalCpu = runningContainers.reduce((acc, c) => acc + (c.stat?.cpu ?? 0), 0);
|
||||
},
|
||||
1000,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
let totalMem = $ref(0);
|
||||
useIntervalFn(
|
||||
() => {
|
||||
totalMem = runningContainers.reduce((acc, c) => acc + (c.stat?.memoryUsage ?? 0), 0);
|
||||
},
|
||||
1000,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function onEnter() {
|
||||
if (data.value.length > 0) {
|
||||
const item = data.value[0];
|
||||
router.push({ name: "container-id", params: { id: item.id } });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
border: 1px solid var(--border-color);
|
||||
.panel-block,
|
||||
.panel-tabs {
|
||||
border-color: var(--border-color);
|
||||
.is-active {
|
||||
border-color: var(--border-hover-color);
|
||||
}
|
||||
.name {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 10px 3px;
|
||||
}
|
||||
</style>
|
||||
79
assets/pages/login.vue
Normal file
79
assets/pages/login.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="hero is-halfheight">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<section class="columns is-centered section">
|
||||
<div class="column is-4">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<form action="" method="post" @submit.prevent="onLogin" ref="form">
|
||||
<div class="field">
|
||||
<label class="label">{{ $t("label.username") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
v-model="username"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t("label.password") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
v-model="password"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="error">{{ $t("error.invalid-auth") }}</p>
|
||||
</div>
|
||||
<div class="field is-grouped is-grouped-centered mt-5">
|
||||
<p class="control">
|
||||
<button class="button is-primary" type="submit">{{ $t("button.login") }}</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
|
||||
setTitle(t("title.login"));
|
||||
|
||||
let error = $ref(false);
|
||||
let username = $ref("");
|
||||
let password = $ref("");
|
||||
let form: HTMLFormElement = $ref();
|
||||
|
||||
async function onLogin() {
|
||||
const response = await fetch(`${config.base}/api/validateCredentials`, {
|
||||
body: new FormData(form),
|
||||
method: "post",
|
||||
});
|
||||
|
||||
if (response.status == 200) {
|
||||
error = false;
|
||||
window.location.href = `${config.base}/`;
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: splash
|
||||
</route>
|
||||
203
assets/pages/settings.vue
Normal file
203
assets/pages/settings.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="section">
|
||||
<div class="has-underline">
|
||||
<h2 class="title is-4">{{ $t("settings.about") }}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span v-html="$t('settings.using-version', { version: currentVersion })"></span>
|
||||
<div
|
||||
v-if="hasUpdate"
|
||||
v-html="$t('settings.update-available', { nextVersion: nextRelease.name, href: nextRelease.html_url })"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="has-underline">
|
||||
<h2 class="title is-4">{{ $t("settings.display") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<o-switch v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </o-switch>
|
||||
</div>
|
||||
<div class="item">
|
||||
<o-switch v-model="showTimestamp"> {{ $t("settings.show-timesamps") }} </o-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<o-switch v-model="softWrap"> {{ $t("settings.soft-wrap") }}</o-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<o-field>
|
||||
<o-dropdown v-model="hourStyle" aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary" type="button">
|
||||
<span class="is-capitalized">{{ hourStyle }}</span>
|
||||
<span class="icon">
|
||||
<carbon-caret-down />
|
||||
</span>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item :value="value" aria-role="listitem" v-for="value in ['auto', '12', '24']" :key="value">
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
{{ $t("settings.12-24-format") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<o-field>
|
||||
<o-dropdown v-model="size" aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary" type="button">
|
||||
<span class="is-capitalized">{{ size }}</span>
|
||||
<span class="icon">
|
||||
<carbon-caret-down />
|
||||
</span>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="value"
|
||||
aria-role="listitem"
|
||||
v-for="value in ['small', 'medium', 'large']"
|
||||
:key="value"
|
||||
>
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="column">{{ $t("settings.font-size") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<o-field>
|
||||
<o-dropdown v-model="lightTheme" aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary" type="button">
|
||||
<span class="is-capitalized">{{ lightTheme }}</span>
|
||||
<span class="icon">
|
||||
<carbon-caret-down />
|
||||
</span>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="value"
|
||||
aria-role="listitem"
|
||||
v-for="value in ['auto', 'dark', 'light']"
|
||||
:key="value"
|
||||
>
|
||||
<span class="is-capitalized">{{ value }}</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="column">{{ $t("settings.color-scheme") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="has-underline">
|
||||
<h2 class="title is-4">{{ $t("settings.options") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<o-switch v-model="search">
|
||||
<span v-html="$t('settings.search')"></span>
|
||||
</o-switch>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<o-switch v-model="showAllContainers"> {{ $t("settings.show-stopped-containers") }} </o-switch>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import gt from "semver/functions/gt";
|
||||
import {
|
||||
search,
|
||||
lightTheme,
|
||||
smallerScrollbars,
|
||||
showTimestamp,
|
||||
hourStyle,
|
||||
showAllContainers,
|
||||
size,
|
||||
softWrap,
|
||||
} from "@/composables/settings";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
setTitle(t("title.settings"));
|
||||
|
||||
const currentVersion = $ref(config.version);
|
||||
let nextRelease = $ref({ html_url: "", name: "" });
|
||||
let hasUpdate = $ref(false);
|
||||
|
||||
async function fetchNextRelease() {
|
||||
if (!["dev", "master"].includes(currentVersion)) {
|
||||
const response = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
|
||||
if (response.ok) {
|
||||
const release = await response.json();
|
||||
hasUpdate = gt(release.tag_name, currentVersion);
|
||||
nextRelease = release;
|
||||
}
|
||||
} else {
|
||||
hasUpdate = true;
|
||||
nextRelease = {
|
||||
html_url: "",
|
||||
name: "master",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fetchNextRelease();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
a.next-release {
|
||||
text-decoration: underline;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.has-underline {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1em 0px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 1em 0;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
25
assets/pages/show.vue
Normal file
25
assets/pages/show.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const store = useContainerStore();
|
||||
const { visibleContainers } = storeToRefs(store);
|
||||
|
||||
watch(visibleContainers, (newValue) => {
|
||||
if (newValue) {
|
||||
if (route.query.name) {
|
||||
const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name);
|
||||
if (container) {
|
||||
router.push({ name: "container-id", params: { id: container.id } });
|
||||
} else {
|
||||
console.error(`No containers found matching name=${route.query.name}. Redirecting to /`);
|
||||
router.push({ name: "index" });
|
||||
}
|
||||
} else {
|
||||
console.error(`Expection query parameter name to be set. Redirecting to /`);
|
||||
router.push({ name: "index" });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template></template>
|
||||
6
assets/shims-vue.d.ts
vendored
Normal file
6
assets/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable */
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
const config = JSON.parse(document.querySelector("script#config__json").textContent);
|
||||
if (config.version == "{{ .Version }}") {
|
||||
config.version = "master";
|
||||
config.base = "";
|
||||
config.authorizationNeeded = false;
|
||||
config.secured = false;
|
||||
} else {
|
||||
config.version = config.version.replace(/^v/, "");
|
||||
config.authorizationNeeded = config.authorizationNeeded === "true";
|
||||
config.secured = config.secured === "true";
|
||||
}
|
||||
|
||||
export default config;
|
||||
@@ -1,120 +0,0 @@
|
||||
import Vue from "vue";
|
||||
import Vuex from "vuex";
|
||||
import storage from "store/dist/store.modern";
|
||||
import { DEFAULT_SETTINGS, DOZZLE_SETTINGS_KEY } from "./settings";
|
||||
import config from "./config";
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
const mql = window.matchMedia("(max-width: 770px)");
|
||||
|
||||
storage.set(DOZZLE_SETTINGS_KEY, { ...DEFAULT_SETTINGS, ...storage.get(DOZZLE_SETTINGS_KEY) });
|
||||
|
||||
const state = {
|
||||
containers: [],
|
||||
activeContainerIds: [],
|
||||
searchFilter: null,
|
||||
isMobile: mql.matches,
|
||||
settings: storage.get(DOZZLE_SETTINGS_KEY),
|
||||
authorizationNeeded: config.authorizationNeeded,
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_CONTAINERS(state, containers) {
|
||||
const containersById = getters.allContainersById({ containers });
|
||||
|
||||
containers.forEach((container) => {
|
||||
container.stat =
|
||||
containersById[container.id] && containersById[container.id].stat
|
||||
? containersById[container.id].stat
|
||||
: { memoryUsage: 0, cpu: 0 };
|
||||
});
|
||||
|
||||
state.containers = containers;
|
||||
},
|
||||
ADD_ACTIVE_CONTAINERS(state, { id }) {
|
||||
state.activeContainerIds.push(id);
|
||||
},
|
||||
REMOVE_ACTIVE_CONTAINER(state, { id }) {
|
||||
state.activeContainerIds.splice(state.activeContainerIds.indexOf(id), 1);
|
||||
},
|
||||
SET_SEARCH(state, filter) {
|
||||
state.searchFilter = filter;
|
||||
},
|
||||
SET_MOBILE_WIDTH(state, value) {
|
||||
state.isMobile = value;
|
||||
},
|
||||
UPDATE_SETTINGS(state, newValues) {
|
||||
state.settings = { ...state.settings, ...newValues };
|
||||
storage.set(DOZZLE_SETTINGS_KEY, state.settings);
|
||||
},
|
||||
UPDATE_CONTAINER(_, { container, data }) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
Vue.set(container, key, value);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const actions = {
|
||||
APPEND_ACTIVE_CONTAINER({ commit }, container) {
|
||||
commit("ADD_ACTIVE_CONTAINERS", container);
|
||||
},
|
||||
REMOVE_ACTIVE_CONTAINER({ commit }, container) {
|
||||
commit("REMOVE_ACTIVE_CONTAINER", container);
|
||||
},
|
||||
SET_SEARCH({ commit }, filter) {
|
||||
commit("SET_SEARCH", filter);
|
||||
},
|
||||
UPDATE_SETTING({ commit }, setting) {
|
||||
commit("UPDATE_SETTINGS", setting);
|
||||
},
|
||||
UPDATE_STATS({ commit, getters: { allContainersById } }, stat) {
|
||||
const container = allContainersById[stat.id];
|
||||
if (container) {
|
||||
commit("UPDATE_CONTAINER", { container, data: { stat } });
|
||||
}
|
||||
},
|
||||
UPDATE_CONTAINER({ commit, getters: { allContainersById } }, event) {
|
||||
switch (event.name) {
|
||||
case "die":
|
||||
const container = allContainersById[event.actorId];
|
||||
commit("UPDATE_CONTAINER", { container, data: { state: "exited" } });
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
allContainersById({ containers }) {
|
||||
return containers.reduce((map, obj) => {
|
||||
map[obj.id] = obj;
|
||||
return map;
|
||||
}, {});
|
||||
},
|
||||
visibleContainers({ containers, settings: { showAllContainers } }) {
|
||||
const filter = showAllContainers ? () => true : (c) => c.state === "running";
|
||||
return containers.filter(filter);
|
||||
},
|
||||
activeContainers({ activeContainerIds }, { allContainersById }) {
|
||||
return activeContainerIds.map((id) => allContainersById[id]);
|
||||
},
|
||||
};
|
||||
|
||||
if (!config.authorizationNeeded) {
|
||||
const es = new EventSource(`${config.base}/api/events/stream`);
|
||||
es.addEventListener("containers-changed", (e) => store.commit("SET_CONTAINERS", JSON.parse(e.data)), false);
|
||||
es.addEventListener("container-stat", (e) => store.dispatch("UPDATE_STATS", JSON.parse(e.data)), false);
|
||||
es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false);
|
||||
}
|
||||
|
||||
mql.addEventListener("change", (e) => store.commit("SET_MOBILE_WIDTH", e.matches));
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
});
|
||||
|
||||
export default store;
|
||||
@@ -1,11 +0,0 @@
|
||||
export const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS";
|
||||
export const DEFAULT_SETTINGS = {
|
||||
search: true,
|
||||
size: "medium",
|
||||
menuWidth: 15,
|
||||
smallerScrollbars: false,
|
||||
showTimestamp: true,
|
||||
showAllContainers: false,
|
||||
lightTheme: false,
|
||||
hourStyle: "auto",
|
||||
};
|
||||
29
assets/stores/config.ts
Normal file
29
assets/stores/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const text = document.querySelector("script#config__json")?.textContent || "{}";
|
||||
|
||||
interface Config {
|
||||
version: string;
|
||||
base: string;
|
||||
authorizationNeeded: boolean | "false" | "true";
|
||||
secured: boolean | "false" | "true";
|
||||
maxLogs: number;
|
||||
}
|
||||
|
||||
const pageConfig = JSON.parse(text);
|
||||
|
||||
const config: Config = {
|
||||
maxLogs: 600,
|
||||
...pageConfig,
|
||||
};
|
||||
|
||||
if (config.version == "{{ .Version }}") {
|
||||
config.version = "master";
|
||||
config.base = "";
|
||||
config.authorizationNeeded = false;
|
||||
config.secured = false;
|
||||
} else {
|
||||
config.version = config.version.replace(/^v/, "");
|
||||
config.authorizationNeeded = config.authorizationNeeded === "true";
|
||||
config.secured = config.secured === "true";
|
||||
}
|
||||
|
||||
export default config as Config;
|
||||
81
assets/stores/container.ts
Normal file
81
assets/stores/container.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
import { Ref, UnwrapNestedRefs } from "vue";
|
||||
import type { ContainerJson, ContainerStat } from "@/types/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
export const useContainerStore = defineStore("container", () => {
|
||||
const containers: Ref<Container[]> = ref([]);
|
||||
const activeContainerIds: Ref<string[]> = ref([]);
|
||||
|
||||
const allContainersById = computed(() =>
|
||||
containers.value.reduce((acc, container) => {
|
||||
acc[container.id] = container;
|
||||
return acc;
|
||||
}, {} as Record<string, Container>)
|
||||
);
|
||||
|
||||
const visibleContainers = computed(() => {
|
||||
const filter = showAllContainers.value ? () => true : (c: Container) => c.state === "running";
|
||||
return containers.value.filter(filter);
|
||||
});
|
||||
|
||||
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
|
||||
|
||||
const es = new EventSource(`${config.base}/api/events/stream`);
|
||||
es.addEventListener("containers-changed", (e: Event) =>
|
||||
setContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[])
|
||||
);
|
||||
es.addEventListener("container-stat", (e) => {
|
||||
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
|
||||
const container = allContainersById.value[stat.id] as unknown as UnwrapNestedRefs<Container>;
|
||||
if (container) {
|
||||
const { id, ...rest } = stat;
|
||||
container.stat = rest;
|
||||
}
|
||||
});
|
||||
es.addEventListener("container-die", (e) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string };
|
||||
const container = allContainersById.value[event.actorId];
|
||||
if (container) {
|
||||
container.state = "dead";
|
||||
}
|
||||
});
|
||||
|
||||
const setContainers = (newContainers: ContainerJson[]) => {
|
||||
containers.value = newContainers.map((c) => {
|
||||
const existing = allContainersById.value[c.id];
|
||||
if (existing) {
|
||||
existing.status = c.status;
|
||||
existing.state = c.state;
|
||||
return existing;
|
||||
}
|
||||
return new Container(c.id, c.created, c.image, c.name, c.command, c.status, c.state);
|
||||
});
|
||||
};
|
||||
|
||||
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
|
||||
const appendActiveContainer = ({ id }: Container) => activeContainerIds.value.push(id);
|
||||
const removeActiveContainer = ({ id }: Container) =>
|
||||
activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1);
|
||||
|
||||
const ready = ref(false);
|
||||
watchOnce(containers, () => (ready.value = true));
|
||||
|
||||
return {
|
||||
containers,
|
||||
activeContainerIds,
|
||||
allContainersById,
|
||||
visibleContainers,
|
||||
activeContainers,
|
||||
currentContainer,
|
||||
appendActiveContainer,
|
||||
removeActiveContainer,
|
||||
ready,
|
||||
};
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
if (import.meta.hot) {
|
||||
// @ts-ignore
|
||||
import.meta.hot.accept(acceptHMRUpdate(useContainerStore, import.meta.hot));
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
@charset "utf-8";
|
||||
@import "~bulma/sass/utilities/initial-variables.sass";
|
||||
|
||||
@import "bulma/sass/utilities/initial-variables.sass";
|
||||
|
||||
$body-background-color: var(--body-background-color);
|
||||
|
||||
@@ -18,6 +17,7 @@ $menu-item-hover-color: var(--menu-item-hover-color);
|
||||
|
||||
$text-strong: var(--text-strong-color);
|
||||
$text: var(--text-color);
|
||||
$text-light: var(--text-light-color);
|
||||
|
||||
$panel-heading-background-color: var(--panel-heading-background-color);
|
||||
$panel-heading-color: var(--panel-heading-color);
|
||||
@@ -25,15 +25,21 @@ $panel-heading-color: var(--panel-heading-color);
|
||||
$link: $turquoise;
|
||||
$link-active: $grey-dark;
|
||||
|
||||
@import "~bulma";
|
||||
@import "../node_modules/splitpanes/dist/splitpanes.css";
|
||||
@import "~buefy/src/scss/utils/_all";
|
||||
@import "~buefy/src/scss/components/_switch";
|
||||
@import "~buefy/src/scss/components/_radio";
|
||||
@import "~buefy/src/scss/components/_modal";
|
||||
@import "~buefy/src/scss/components/_autocomplete";
|
||||
$dark-toolbar-color: rgba($black-bis, 0.7);
|
||||
$light-toolbar-color: rgba($grey-darker, 0.7);
|
||||
|
||||
html {
|
||||
@import "bulma/bulma.sass";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/utils/all.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/autocomplete.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/button.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/modal.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/switch.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/tooltip.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/dropdown.scss";
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/skeleton.scss";
|
||||
@import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
@mixin dark {
|
||||
--scheme-main: #{$black};
|
||||
--scheme-main-bis: #{$black-bis};
|
||||
--scheme-main-ter: #{$black-ter};
|
||||
@@ -46,6 +52,8 @@ html {
|
||||
--secondary-color: #{$yellow};
|
||||
|
||||
--body-background-color: #{$black-bis};
|
||||
--action-toolbar-background-color: #{$dark-toolbar-color};
|
||||
--body-color: #{$grey-lighter};
|
||||
|
||||
--menu-item-active-background-color: var(--primary-color);
|
||||
--menu-item-color: hsl(0, 6%, 87%);
|
||||
@@ -57,9 +65,10 @@ html {
|
||||
|
||||
--text-strong-color: #{$grey-lightest};
|
||||
--text-color: #{$grey-lighter};
|
||||
--text-light-color: #{$grey};
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@mixin light {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
@@ -72,17 +81,40 @@ html {
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-active-background-color: var(--primary-color);
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
--menu-item-hover-color: #{$black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
--text-light-color: #{$grey};
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@include dark;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@include light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
@include dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
html {
|
||||
@include light;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -121,28 +153,18 @@ html.has-custom-scrollbars {
|
||||
}
|
||||
}
|
||||
|
||||
.is-settings-control {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
&:hover {
|
||||
border-color: var(--border-hover-color) !important;
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
color: unset;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 770px) {
|
||||
.splitpanes__pane {
|
||||
overflow: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-device-width: 480px) {
|
||||
body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.splitpanes__splitter {
|
||||
z-index: 99;
|
||||
}
|
||||
@@ -156,3 +178,23 @@ html.has-custom-scrollbars {
|
||||
.modal {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.button .button-wrapper > span {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
mark {
|
||||
border-radius: 2px;
|
||||
background-color: var(--secondary-color);
|
||||
animation: pops 200ms ease-out;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
0% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
18
assets/types/Container.d.ts
vendored
Normal file
18
assets/types/Container.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ContainerStat {
|
||||
readonly id: string;
|
||||
readonly cpu: number;
|
||||
readonly memory: number;
|
||||
readonly memoryUsage: number;
|
||||
}
|
||||
|
||||
export type ContainerJson = {
|
||||
readonly id: string;
|
||||
readonly created: number;
|
||||
readonly image: string;
|
||||
readonly name: string;
|
||||
readonly command: string;
|
||||
readonly status: string;
|
||||
readonly state: ContainerState;
|
||||
};
|
||||
|
||||
export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting";
|
||||
1
assets/types/Point.d.ts
vendored
Normal file
1
assets/types/Point.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
type Point = { x: number; y: number };
|
||||
47
assets/utils/index.ts
Normal file
47
assets/utils/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed, ComputedRef } from "vue";
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
|
||||
const result: Record<string, any> = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = obj[key];
|
||||
const newPath = path.concat(key);
|
||||
if (isObject(value)) {
|
||||
Object.assign(result, flattenJSON(value, newPath));
|
||||
} else {
|
||||
result[newPath.join(".")] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function arrayEquals(a: string[], b: string[]): boolean {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
|
||||
export function persistentVisibleKeys(container: ComputedRef<Container>) {
|
||||
return computed(() => useStorage(stripVersion(container.value.image) + ":" + container.value.command, []));
|
||||
}
|
||||
|
||||
export function stripVersion(label: string) {
|
||||
const [name, _] = label.split(":");
|
||||
return name;
|
||||
}
|
||||
17
docker/calculation.go
Normal file
17
docker/calculation.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package docker
|
||||
|
||||
import "github.com/docker/docker/api/types"
|
||||
|
||||
func calculateMemUsageUnixNoCache(mem types.MemoryStats) float64 {
|
||||
// re implementation of the docker calculation
|
||||
// https://github.com/docker/cli/blob/53f8ed4bec07084db4208f55987a2ea94b7f01d6/cli/command/container/stats_helpers.go#L227-L249
|
||||
// cgroup v1
|
||||
if v, isCGroup := mem.Stats["total_inactive_file"]; isCGroup && v < mem.Usage {
|
||||
return float64(mem.Usage - v)
|
||||
}
|
||||
// cgroup v2
|
||||
if v := mem.Stats["inactive_file"]; v < mem.Usage {
|
||||
return float64(mem.Usage - v)
|
||||
}
|
||||
return float64(mem.Usage)
|
||||
}
|
||||
57
docker/calculation_test.go
Normal file
57
docker/calculation_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_calculateMemUsageUnixNoCache(t *testing.T) {
|
||||
type args struct {
|
||||
mem types.MemoryStats
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "with cgroup v1",
|
||||
args: args{
|
||||
mem: types.MemoryStats{
|
||||
Usage: 100,
|
||||
Stats: map[string]uint64{
|
||||
"total_inactive_file": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 99,
|
||||
},
|
||||
{
|
||||
name: "with cgroup v2",
|
||||
args: args{
|
||||
mem: types.MemoryStats{
|
||||
Usage: 100,
|
||||
Stats: map[string]uint64{
|
||||
"inactive_file": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 98,
|
||||
},
|
||||
{
|
||||
name: "without cgroup",
|
||||
args: args{
|
||||
mem: types.MemoryStats{
|
||||
Usage: 100,
|
||||
},
|
||||
},
|
||||
want: 100,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, calculateMemUsageUnixNoCache(tt.args.mem), "calculateMemUsageUnixNoCache(%v)", tt.args.mem)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type dockerProxy interface {
|
||||
Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error)
|
||||
ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
|
||||
ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error)
|
||||
Ping(ctx context.Context) (types.Ping, error)
|
||||
}
|
||||
|
||||
// Client is a proxy around the docker client
|
||||
@@ -38,6 +39,7 @@ type Client interface {
|
||||
Events(context.Context) (<-chan ContainerEvent, <-chan error)
|
||||
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time) (io.ReadCloser, error)
|
||||
ContainerStats(context.Context, string, chan<- ContainerStat) error
|
||||
Ping(context.Context) (types.Ping, error)
|
||||
}
|
||||
|
||||
// NewClientWithFilters creates a new instance of Client with docker filters
|
||||
@@ -75,8 +77,8 @@ func (d *dockerClient) FindContainer(id string) (Container, error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == false {
|
||||
return container, fmt.Errorf("Unable to find container with id: %s", id)
|
||||
if !found {
|
||||
return container, fmt.Errorf("unable to find container with id: %s", id)
|
||||
}
|
||||
|
||||
return container, nil
|
||||
@@ -136,11 +138,16 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan
|
||||
log.Errorf("decoder for stats api returned an unknown error %v", err)
|
||||
}
|
||||
|
||||
ncpus := uint8(v.CPUStats.OnlineCPUs)
|
||||
if ncpus == 0 {
|
||||
ncpus = uint8(len(v.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
|
||||
var (
|
||||
cpuDelta = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta = float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage)
|
||||
cpuPercent = int64((cpuDelta / systemDelta) * float64(len(v.CPUStats.CPUUsage.PercpuUsage)) * 100)
|
||||
memUsage = int64(v.MemoryStats.Usage - v.MemoryStats.Stats["cache"])
|
||||
cpuPercent = int64((cpuDelta / systemDelta) * float64(ncpus) * 100)
|
||||
memUsage = int64(calculateMemUsageUnixNoCache(v.MemoryStats))
|
||||
memPercent = int64(float64(memUsage) / float64(v.MemoryStats.Limit) * 100)
|
||||
)
|
||||
|
||||
@@ -165,6 +172,14 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan
|
||||
func (d *dockerClient) ContainerLogs(ctx context.Context, id string, tailSize int, since string) (io.ReadCloser, error) {
|
||||
log.WithField("id", id).WithField("since", since).Debug("streaming logs for container")
|
||||
|
||||
if since != "" {
|
||||
if millis, err := strconv.ParseInt(since, 10, 64); err == nil {
|
||||
since = time.UnixMicro(millis).Add(time.Millisecond).Format(time.RFC3339Nano)
|
||||
} else {
|
||||
log.WithError(err).Debug("unable to parse since")
|
||||
}
|
||||
}
|
||||
|
||||
options := types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
@@ -174,6 +189,7 @@ func (d *dockerClient) ContainerLogs(ctx context.Context, id string, tailSize in
|
||||
Since: since,
|
||||
}
|
||||
|
||||
log.Debugf("streaming logs from Docker with option: %+v", options)
|
||||
reader, err := d.cli.ContainerLogs(ctx, id, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -221,10 +237,12 @@ func (d *dockerClient) ContainerLogsBetweenDates(ctx context.Context, id string,
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Timestamps: true,
|
||||
Since: strconv.FormatInt(from.Unix(), 10),
|
||||
Until: strconv.FormatInt(to.Unix(), 10),
|
||||
Since: from.Format(time.RFC3339),
|
||||
Until: to.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
log.Debugf("fetching logs from Docker with option: %+v", options)
|
||||
|
||||
reader, err := d.cli.ContainerLogs(ctx, id, options)
|
||||
|
||||
if err != nil {
|
||||
@@ -238,3 +256,7 @@ func (d *dockerClient) ContainerLogsBetweenDates(ctx context.Context, id string,
|
||||
|
||||
return newLogReader(reader, containerJSON.Config.Tty), nil
|
||||
}
|
||||
|
||||
func (d *dockerClient) Ping(ctx context.Context) (types.Ping, error) {
|
||||
return d.cli.Ping(ctx)
|
||||
}
|
||||
|
||||
@@ -26,3 +26,9 @@ type ContainerEvent struct {
|
||||
ActorID string `json:"actorId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
Message any `json:"m,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Id uint32 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
3
e2e/.gitignore
vendored
Normal file
3
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
videos
|
||||
screenshots
|
||||
__diff_output__
|
||||
11
e2e/Dockerfile
Normal file
11
e2e/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM cypress/included:10.10.0
|
||||
|
||||
RUN apt install curl && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
|
||||
|
||||
WORKDIR /e2e
|
||||
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch
|
||||
|
||||
COPY package.json tsconfig.json ./
|
||||
RUN pnpm install -r --offline
|
||||
13
e2e/cypress.config.ts
Normal file
13
e2e/cypress.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import { initPlugin } from '@frsource/cypress-plugin-visual-regression-diff/dist/plugins';
|
||||
|
||||
export default defineConfig({
|
||||
fixturesFolder: false,
|
||||
projectId: "8cua4m",
|
||||
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
initPlugin(on, config);
|
||||
},
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user