Compare commits
379 Commits
Author | SHA1 | Date |
---|---|---|
|
3860415429 | |
|
dec906a69b | |
|
118b9b8870 | |
|
864bd74029 | |
|
aedf4d4222 | |
|
a4d7c598f9 | |
|
480e4cc7fd | |
|
1016df4a25 | |
|
46d7ba2a9f | |
|
19c8368dff | |
|
1c322fdac5 | |
|
53e8c26380 | |
|
400a908cf9 | |
|
edd77182a5 | |
|
6e44c64441 | |
|
51beb7249f | |
|
79fd2a6d39 | |
|
30c3decd65 | |
|
23fc32e92f | |
|
f458a1a0e4 | |
|
52aaad6339 | |
|
dd70d6b595 | |
|
5d425aadb1 | |
|
c02a24ee94 | |
|
e03fc8ad28 | |
|
3367f13d80 | |
|
b601ba255b | |
|
ccb4bb85ae | |
|
b0dfe348bd | |
|
a3a1a17af3 | |
|
ca9f0a08e1 | |
|
3f5b63a36f | |
|
ab3560fc2b | |
|
cb428747e2 | |
|
aa3383db43 | |
|
026591c8d4 | |
|
a70b5b56ff | |
|
eaa68df583 | |
|
2388f309d8 | |
|
a9c7cb1cd2 | |
|
b506e8a6a4 | |
|
c082a0c41f | |
|
970ebcf902 | |
|
b223feb441 | |
|
469a62333f | |
|
c38944e948 | |
|
5efbd4d316 | |
|
c54567115e | |
|
a14e7e947e | |
|
868b0c81b6 | |
|
a3cb09863a | |
|
b26f5d2bde | |
|
0038092193 | |
|
0739cb8b68 | |
|
70c38d9aa7 | |
|
237b25086c | |
|
71dbda01da | |
|
5deb6ce443 | |
|
ee398d4d98 | |
|
29b51f836f | |
|
ea8ceba32a | |
|
9bec3934bb | |
|
fdfd04d4bf | |
|
410dcd9e57 | |
|
ea44253c02 | |
|
e2093a89bf | |
|
908d4c7007 | |
|
6843314bad | |
|
bdfa31e4b6 | |
|
aa8c5495c1 | |
|
65cfdd1bcc | |
|
6bd1b23a64 | |
|
8c30daec15 | |
|
86f42fcc10 | |
|
1bcde26e35 | |
|
bf71c6db0e | |
|
88e2c6cb43 | |
|
6eeb701439 | |
|
028a240f49 | |
|
eefa7b1346 | |
|
3321fbf6fd | |
|
4ed93902a6 | |
|
b4405eb7db | |
|
04bc103583 | |
|
f17bb744f4 | |
|
152fbe962f | |
|
a2b0fc183f | |
|
f536b0f23b | |
|
3c43f960c3 | |
|
78bfaf6e16 | |
|
50bec33870 | |
|
f0c9b74545 | |
|
e5fed29427 | |
|
4dd52a8c8e | |
|
abf5534165 | |
|
455a911154 | |
|
e2264b33b0 | |
|
84b493b26a | |
|
c9e618d418 | |
|
e8f76ce8ae | |
|
70354c14ec | |
|
d472836d5f | |
|
f00a959db3 | |
|
aa98da31d6 | |
|
18ba04a5b1 | |
|
c9beceab76 | |
|
6abf4823a6 | |
|
2133fe44ca | |
|
8139481ea7 | |
|
d41421389c | |
|
1cf126e78d | |
|
4d29343c1f | |
|
a682aabb0b | |
|
971c89a5cf | |
|
b72855b707 | |
|
974477cb49 | |
|
1bf4b0eee1 | |
|
33caf873d6 | |
|
fc0deac074 | |
|
eb5f69bcbc | |
|
28c326d608 | |
|
83319c06d7 | |
|
dbe3d995b4 | |
|
8ab82534e1 | |
|
fc67590bde | |
|
2e28c1b44b | |
|
c7c647d728 | |
|
4fd73411de | |
|
b8e1c2fca4 | |
|
cda10ed279 | |
|
2e620f7050 | |
|
c0fdfc0ce7 | |
|
d1958a3290 | |
|
668620425f | |
|
cc696f9642 | |
|
adf9e4347f | |
|
41d0dc6ffc | |
|
d99a397ae3 | |
|
1091f5d34c | |
|
35c6918b9b | |
|
006f3b2a05 | |
|
d2e3392283 | |
|
3900052adb | |
|
bb9c25a880 | |
|
af6748ac59 | |
|
66715d05a5 | |
|
656e18afe5 | |
|
eca6bd523e | |
|
13b9a38095 | |
|
de7cdb5bd4 | |
|
e3ccb06a96 | |
|
8e48da3cc7 | |
|
635cd6ca8b | |
|
aa291f742d | |
|
2d2954d81c | |
|
fdf2c477f2 | |
|
f3c5065259 | |
|
78f3abaf5e | |
|
6430deeaea | |
|
a13bf788eb | |
|
b745b9f45d | |
|
b46cc6c436 | |
|
00b1efc43d | |
|
f8ef25a323 | |
|
bb4ab109e7 | |
|
3a799b7b4a | |
|
71ffbde648 | |
|
9bd958ef24 | |
|
ad2c4c432b | |
|
c4d41b12dc | |
|
53563acac0 | |
|
23b68d9e19 | |
|
9a10aba67e | |
|
a431467b5f | |
|
78b7700b1b | |
|
28d2336124 | |
|
2250e15971 | |
|
70ed556e80 | |
|
13e80da978 | |
|
64ae79f565 | |
|
cb9a9ebb8a | |
|
434568e66d | |
|
7cf842ed4c | |
|
3057012710 | |
|
1c4462b161 | |
|
30835ac469 | |
|
2405a79ace | |
|
e92eb525e7 | |
|
094705e87d | |
|
f1e1a89baf | |
|
29ffd83486 | |
|
df71ac7049 | |
|
32f1b71073 | |
|
74a6b9b0e1 | |
|
09264134ec | |
|
a2331675d7 | |
|
e1f022908c | |
|
c0415fe23d | |
|
e271eafc9e | |
|
a8109c4bb2 | |
|
ce1b9b706f | |
|
450e451781 | |
|
7faca878a3 | |
|
74d789ac8e | |
|
fd58357a04 | |
|
f09ee89a96 | |
|
7a579d0d0b | |
|
f94134faa0 | |
|
581a1b79ca | |
|
b361e9b0be | |
|
4032c80add | |
|
f3a43c8083 | |
|
b610bd7861 | |
|
5a86bab647 | |
|
fe2f8a0480 | |
|
7cbdc83884 | |
|
124d221b9f | |
|
edaef2a78c | |
|
d75635bf70 | |
|
0b37b89f9b | |
|
cdac3c4496 | |
|
655cd539ca | |
|
76783c36fb | |
|
3c7003291c | |
|
4cbc0b98e7 | |
|
a679858478 | |
|
94d642c4de | |
|
ff2043c0e2 | |
|
c2c1e9cfba | |
|
e3e0ed0a91 | |
|
74ab68b280 | |
|
cee25a8015 | |
|
bbcbc8b661 | |
|
673159dc85 | |
|
700a54081b | |
|
9564272fd8 | |
|
a2336b4fc8 | |
|
6aca08de3e | |
|
f0ff3e9ed5 | |
|
47014933bf | |
|
b674e291b4 | |
|
15f6314597 | |
|
06853f7c75 | |
|
8948f76f16 | |
|
6d3526c765 | |
|
42fa24debd | |
|
022ee20eed | |
|
9402af2433 | |
|
6538313da8 | |
|
649cc71680 | |
|
c76a0a505f | |
|
ffed68ae4c | |
|
7fecbc2b53 | |
|
ee57346df6 | |
|
70235cc295 | |
|
718e89a641 | |
|
0a26ac6300 | |
|
ba8d19a121 | |
|
a663ecdeb5 | |
|
a3f9c62f4e | |
|
4bf35e736e | |
|
042ef3075a | |
|
cd36dbda48 | |
|
56bc609420 | |
|
dbff8f9e79 | |
|
1a6756905d | |
|
aab2531d40 | |
|
2e7f832754 | |
|
f3f12479fd | |
|
e1362fce45 | |
|
f1cfa1778f | |
|
b7ba179e62 | |
|
bc53c405a5 | |
|
5d2080aafb | |
|
d989cdd85b | |
|
115aa9d079 | |
|
4258a45498 | |
|
cefc5a5078 | |
|
8c69ce7257 | |
|
c497423711 | |
|
927678ebbb | |
|
bc66c63b3d | |
|
72ef406cd8 | |
|
51a1b1b35f | |
|
e87b02e14c | |
|
0d6765a757 | |
|
ed1b9d9b54 | |
|
d66d7c9a49 | |
|
c2bf4128f7 | |
|
8d7c8cb3ed | |
|
9b3f2ba726 | |
|
7f2fac7fe8 | |
|
13dbc9b3b6 | |
|
1d1fab54d8 | |
|
ed18d8b5ee | |
|
e071e65701 | |
|
b823f18794 | |
|
ac6d68d17d | |
|
23087a5374 | |
|
a71f5e0070 | |
|
1340f911c8 | |
|
37efe5e72a | |
|
36a8c38877 | |
|
18f1b976c6 | |
|
84b73bd5e7 | |
|
5e970982a2 | |
|
535fdabb0c | |
|
df5a577919 | |
|
554b2b9f72 | |
|
bb676b974a | |
|
660fc0cf93 | |
|
d2bb2995c5 | |
|
4dc8839a51 | |
|
4c9d75303c | |
|
df3865dc7d | |
|
10ec866037 | |
|
665f8801ca | |
|
93b04071e2 | |
|
b7433fadaa | |
|
f597002378 | |
|
3fe8767c44 | |
|
2bc7a57773 | |
|
0fb93258e9 | |
|
21a569d9bb | |
|
e28bb57a25 | |
|
5b9f261824 | |
|
7fa1ecfa0a | |
|
f98229b9fa | |
|
bce4e2323e | |
|
b06217adc0 | |
|
f5611a2635 | |
|
c5abaa6573 | |
|
8f15656f37 | |
|
860e1eaac3 | |
|
d31da4a055 | |
|
566a7e212f | |
|
c7a365e8e9 | |
|
08c42ca85c | |
|
f7f394972d | |
|
94c7b7ade5 | |
|
44770a4a8e | |
|
d819502bc7 | |
|
e942f41f66 | |
|
ac76131f18 | |
|
e0d5e0c874 | |
|
f966fec0a3 | |
|
86b3ac0bd4 | |
|
5dd49a78dd | |
|
116513917d | |
|
d4d1c33cb3 | |
|
d59221f11e | |
|
b7c10b33e7 | |
|
2f25b524cd | |
|
84b42e6461 | |
|
f9abba37d2 | |
|
827ca1f2e7 | |
|
ba55cbcbd3 | |
|
686f73c3dd | |
|
464a85867d | |
|
af6b4257f9 | |
|
b688ded610 | |
|
a78c6cdb26 | |
|
8573d24a47 | |
|
7c4f0197ba | |
|
52490cb304 | |
|
7934fc275a | |
|
2d3225dbcf | |
|
988e8e3339 | |
|
ab8077999d | |
|
eb8f68b628 | |
|
e2f33af1c7 | |
|
8201004478 | |
|
379bb5e623 | |
|
6b1fcb3779 | |
|
8662027a68 | |
|
f0e54f280c | |
|
9671f89770 | |
|
2f2fa6bb02 | |
|
d40839506c |
|
@ -3,6 +3,12 @@ name: Release Linux App
|
|||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Version tag'
|
||||
required: true
|
||||
default: '1.0.0'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
@ -12,6 +18,7 @@ jobs:
|
|||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v3
|
||||
|
@ -23,12 +30,26 @@ jobs:
|
|||
tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g')
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Normalise platform arch
|
||||
id: normalise_platform_arch
|
||||
run: |
|
||||
if [ "${{ matrix.platform }}" == "linux/amd64" ]; then
|
||||
echo "arch=x86_64" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ matrix.platform }}" == "linux/aarch64" ]; then
|
||||
echo "arch=aarch64" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Normalise version tag
|
||||
id: normalise_version
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ github.event.release.tag_name }}" == "" ]; then
|
||||
version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
|
@ -43,12 +64,12 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse-dev libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash
|
||||
|
@ -65,7 +86,7 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \
|
||||
-ldflags "-X main.version=${{ github.event.release.tag_name }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.LINUX_GA_SECRET }}" \
|
||||
-ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.LINUX_GA_SECRET }}" \
|
||||
-o tiny-rdm
|
||||
|
||||
- name: Setup control template
|
||||
|
@ -98,11 +119,57 @@ jobs:
|
|||
sed -i 's/0.0.0/${{ steps.normalise_version.outputs.version }}/g' "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64/DEBIAN/control"
|
||||
dpkg-deb --build -Zxz "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64"
|
||||
|
||||
- name: Upload release asset
|
||||
shell: bash
|
||||
working-directory: ./build/linux/
|
||||
- name: Package up appimage file
|
||||
run: |
|
||||
filepath="tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64.deb"
|
||||
filename="tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb"
|
||||
upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets"
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary @$filepath "$upload_url?name=$filename"
|
||||
curl https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-${{ steps.normalise_platform_arch.outputs.arch }}.AppImage \
|
||||
-o linuxdeploy \
|
||||
-L
|
||||
chmod u+x linuxdeploy
|
||||
|
||||
./linuxdeploy --appdir AppDir
|
||||
|
||||
pushd AppDir
|
||||
# Copy WebKit files.
|
||||
find /usr/lib* -name WebKitNetworkProcess -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true
|
||||
find /usr/lib* -name WebKitWebProcess -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true
|
||||
find /usr/lib* -name libwebkit2gtkinjectedbundle.so -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true
|
||||
popd
|
||||
|
||||
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/512x512/apps
|
||||
build_dir="build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64"
|
||||
|
||||
cp -r $build_dir/usr/share/icons/hicolor/512x512/apps/tiny-rdm.png AppDir/usr/share/icons/hicolor/512x512/apps/
|
||||
cp $build_dir/usr/local/bin/tiny-rdm AppDir/usr/bin/
|
||||
|
||||
|
||||
sed -i 's#/usr/local/bin/tiny-rdm#tiny-rdm#g' $build_dir/usr/share/applications/tiny-rdm.desktop
|
||||
|
||||
curl -o linuxdeploy-plugin-gtk.sh "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh"
|
||||
|
||||
sed -i '/XDG_DATA_DIRS/a export WEBKIT_DISABLE_COMPOSITING_MODE=1' linuxdeploy-plugin-gtk.sh
|
||||
chmod +x linuxdeploy-plugin-gtk.sh
|
||||
|
||||
curl -o AppDir/AppRun https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-${{ steps.normalise_platform_arch.outputs.arch }} -L
|
||||
|
||||
./linuxdeploy --appdir AppDir \
|
||||
--output=appimage \
|
||||
--plugin=gtk \
|
||||
-e $build_dir/usr/local/bin/tiny-rdm \
|
||||
-d $build_dir/usr/share/applications/tiny-rdm.desktop
|
||||
|
||||
- name: Rename deb
|
||||
working-directory: ./build/linux
|
||||
run: mv "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64.deb" "tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb"
|
||||
|
||||
- name: Rename appimage
|
||||
run: mv Tiny_RDM-${{ steps.normalise_platform_arch.outputs.arch }}.AppImage "tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.AppImage"
|
||||
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ steps.normalise_version.outputs.version }}
|
||||
files: |
|
||||
./build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb
|
||||
tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.AppImage
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -3,6 +3,12 @@ name: Release macOS App
|
|||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Version tag'
|
||||
required: true
|
||||
default: '1.0.0'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
@ -29,8 +35,13 @@ jobs:
|
|||
id: normalise_version
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ github.event.release.tag_name }}" == "" ]; then
|
||||
version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
|
@ -54,7 +65,7 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash
|
||||
|
@ -71,7 +82,7 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \
|
||||
-ldflags "-X main.version=${{ github.event.release.tag_name }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.MAC_GA_SECRET }}"
|
||||
-ldflags "-X main.version=${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.MAC_GA_SECRET }}"
|
||||
|
||||
# - name: Notarise macOS app + create dmg
|
||||
# shell: bash
|
||||
|
@ -80,35 +91,42 @@ jobs:
|
|||
# AC_USERNAME: ${{ secrets.AC_USERNAME }}
|
||||
# AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
|
||||
- name: Compress macOS app
|
||||
shell: bash
|
||||
working-directory: ./build/bin
|
||||
run: |
|
||||
mv tinyrdm.app "Tiny RDM.app"
|
||||
zip -r TinyRDM-${{ steps.normalise_platform.outputs.tag }}.zip "Tiny RDM.app"
|
||||
|
||||
- name: Upload release asset (ZIP Package)
|
||||
shell: bash
|
||||
working-directory: ./build/bin/
|
||||
run: |
|
||||
filepath="TinyRDM-${{ steps.normalise_platform.outputs.tag }}.zip"
|
||||
filename="TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip"
|
||||
upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets"
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -H "Content-Type: application/zip" --data-binary @$filepath "$upload_url?name=$filename"
|
||||
- name: Checkout create-image
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: create-dmg/create-dmg
|
||||
path: ./build/create-dmg
|
||||
ref: master
|
||||
|
||||
- name: Build macOS DMG
|
||||
shell: bash
|
||||
working-directory: ./build/bin
|
||||
working-directory: ./build
|
||||
run: |
|
||||
rm TinyRDM-${{ steps.normalise_platform.outputs.tag }}.zip
|
||||
ln -s /Applications Applications
|
||||
hdiutil create -volname "Tiny RDM" -srcfolder . -ov -format UDBZ TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg
|
||||
mv bin/tinyrdm.app "bin/Tiny RDM.app"
|
||||
./create-dmg/create-dmg \
|
||||
--no-internet-enable \
|
||||
--volname "Tiny RDM" \
|
||||
--volicon "bin/Tiny RDM.app/Contents/Resources/iconfile.icns" \
|
||||
--background "dmg/background.tiff" \
|
||||
--text-size 12 \
|
||||
--window-pos 400 400 \
|
||||
--window-size 660 450 \
|
||||
--icon-size 80 \
|
||||
--icon "Tiny RDM.app" 180 180 \
|
||||
--hide-extension "Tiny RDM.app" \
|
||||
--app-drop-link 480 180 \
|
||||
--add-file "Repair" "dmg/fix-app" 230 290 \
|
||||
--add-file "损坏修复" "dmg/fix-app_zh" 430 290 \
|
||||
"bin/TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg" \
|
||||
"bin"
|
||||
|
||||
- name: Rename dmg
|
||||
working-directory: ./build/bin
|
||||
run: mv "TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg" "TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg"
|
||||
|
||||
- name: Upload release asset (DMG Package)
|
||||
shell: bash
|
||||
working-directory: ./build/bin/
|
||||
run: |
|
||||
filepath="TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg"
|
||||
filename="TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg"
|
||||
upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets"
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -H "Content-Type: application/x-apple-diskimage" --data-binary @$filepath "$upload_url?name=$filename"
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ steps.normalise_version.outputs.version }}
|
||||
files: ./build/bin/TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -3,6 +3,12 @@ name: Release Windows App
|
|||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Version tag'
|
||||
required: true
|
||||
default: '1.0.0'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
@ -12,6 +18,7 @@ jobs:
|
|||
matrix:
|
||||
platform:
|
||||
- windows/amd64
|
||||
- windows/arm64
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v3
|
||||
|
@ -20,15 +27,27 @@ jobs:
|
|||
id: normalise_platform
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g')
|
||||
tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g' -e 's/amd64/x64/g')
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Normalise platform name
|
||||
id: normalise_platform_name
|
||||
shell: bash
|
||||
run: |
|
||||
pname=$(echo "${{ matrix.platform }}" | sed 's/windows\///g')
|
||||
echo "pname=$pname" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Normalise version tag
|
||||
id: normalise_version
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ github.event.release.tag_name }}" == "" ]; then
|
||||
version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
|
@ -38,7 +57,7 @@ jobs:
|
|||
- name: Install chocolatey
|
||||
uses: crazy-max/ghaction-chocolatey@v2
|
||||
with:
|
||||
args: install nsis jq upx
|
||||
args: install nsis jq
|
||||
|
||||
- name: Install wails
|
||||
shell: bash
|
||||
|
@ -47,7 +66,7 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash
|
||||
|
@ -64,27 +83,29 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \
|
||||
-upx -webview2 embed \
|
||||
-ldflags "-X main.version=${{ github.event.release.tag_name }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.WINDOWS_GA_SECRET }}"
|
||||
-webview2 embed \
|
||||
-ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.WINDOWS_GA_SECRET }}"
|
||||
|
||||
- name: Compress portable binary
|
||||
working-directory: ./build/bin
|
||||
run: Compress-Archive "Tiny RDM.exe" tiny-rdm.zip
|
||||
run: Compress-Archive "Tiny RDM.exe" "TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip"
|
||||
|
||||
- name: Upload release asset (Portable)
|
||||
shell: bash
|
||||
working-directory: ./build/bin
|
||||
run: |
|
||||
filepath="tiny-rdm.zip"
|
||||
filename="TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip"
|
||||
upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets"
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -H "Content-Type: application/zip" --data-binary @$filepath "$upload_url?name=$filename"
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ steps.normalise_version.outputs.version }}
|
||||
files: ./build/bin/TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Windows NSIS installer
|
||||
shell: bash
|
||||
run: CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} -nsis -upx -webview2 embed -ldflags "-X main.version=${{ github.event.release.tag_name }}"
|
||||
run: |
|
||||
CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \
|
||||
-nsis -webview2 embed \
|
||||
-ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }}"
|
||||
|
||||
- name: Codesign Windows NSIS installer
|
||||
shell: powershell
|
||||
working-directory: ./build/bin
|
||||
run: |
|
||||
echo "Creating certificate file"
|
||||
|
@ -92,13 +113,15 @@ jobs:
|
|||
Set-Content -Path certificate\certificate.txt -Value '${{ secrets.WIN_SIGNING_CERT }}'
|
||||
certutil -decode certificate\certificate.txt certificate\certificate.pfx
|
||||
echo "Signing TinyRDM installer"
|
||||
& 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /fd sha256 /tr http://ts.ssl.com /f certificate\certificate.pfx /p '${{ secrets.WIN_SIGNING_CERT_PASSWORD }}' TinyRDM-amd64-installer.exe
|
||||
& 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe' sign /fd sha256 /tr http://timestamp.digicert.com /f certificate\certificate.pfx /p '${{ secrets.WIN_SIGNING_CERT_PASSWORD }}' TinyRDM-${{ steps.normalise_platform_name.outputs.pname }}-installer.exe
|
||||
|
||||
- name: Rename installer
|
||||
working-directory: ./build/bin
|
||||
run: Rename-Item -Path "TinyRDM-${{ steps.normalise_platform_name.outputs.pname }}-installer.exe" -NewName "TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe"
|
||||
|
||||
- name: Upload release asset (Installer)
|
||||
shell: bash
|
||||
working-directory: ./build/bin/
|
||||
run: |
|
||||
filepath="TinyRDM-amd64-installer.exe"
|
||||
filename="TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe"
|
||||
upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets"
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary @$filepath "$upload_url?name=$filename"
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ steps.normalise_version.outputs.version }}
|
||||
files: ./build/bin/TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
77
README.md
77
README.md
|
@ -1,16 +1,21 @@
|
|||
<h4 align="right"><strong>English</strong> | <a href="https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md">简体中文</a></h4>
|
||||
<div align="center">
|
||||
<a href="https://github.com/tiny-craft/tiny-rdm/"><img src="build/appicon.png" width="120"/></a>
|
||||
</div>
|
||||
<h1 align="center">Tiny RDM</h1>
|
||||
<h4 align="center"><strong>English</strong> | <a href="https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md">
|
||||
简体中文</a> | <a href="https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md">日本語</a></h4>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)
|
||||
[](https://github.com/tiny-craft/tiny-rdm/releases)
|
||||

|
||||
[](https://github.com/tiny-craft/tiny-rdm/stargazers)
|
||||
[](https://github.com/tiny-craft/tiny-rdm/fork)
|
||||
[](https://discord.gg/VTFbBMGjWh)
|
||||
[](https://twitter.com/Lykin53448)
|
||||
|
||||
<strong>Tiny RDM is a modern lightweight cross-platform Redis desktop manager available for Mac, Windows, and Linux.</strong>
|
||||
<strong>Tiny RDM is a modern lightweight cross-platform Redis desktop manager available for Mac, Windows, and
|
||||
Linux.</strong>
|
||||
</div>
|
||||
|
||||
<picture>
|
||||
|
@ -19,26 +24,34 @@
|
|||
<img alt="screenshot" src="screenshots/dark_en.png">
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/dark_en2.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="screenshots/light_en2.png">
|
||||
<img alt="screenshot" src="screenshots/dark_en2.png">
|
||||
</picture>
|
||||
|
||||
## Feature
|
||||
|
||||
* Super lightweight, built on Webview2, without embedded browsers (Thanks to [Wails](https://github.com/wailsapp/wails)).
|
||||
* More elegant UI, frameless, offering light and dark themes (Thanks to [Naive UI](https://github.com/tusen-ai/naive-ui)
|
||||
* Super lightweight, built on Webview2, without embedded browsers (Thanks
|
||||
to [Wails](https://github.com/wailsapp/wails)).
|
||||
* Provides visually and user-friendly UI, light and dark themes (Thanks to [Naive UI](https://github.com/tusen-ai/naive-ui)
|
||||
and [IconPark](https://iconpark.oceanengine.com)).
|
||||
* Multi-language support ([Need more languages ? Click here to contribute](.github/CONTRIBUTING.md)).
|
||||
* Better connection management: supports SSH Tunnel/SSL/Sentinel Mode/Cluster Mode.
|
||||
* Better connection management: supports SSH Tunnel/SSL/Sentinel Mode/Cluster Mode/HTTP proxy/SOCKS5 proxy.
|
||||
* Visualize key value operations, CRUD support for Lists, Hashes, Strings, Sets, Sorted Sets, and Streams.
|
||||
* Support multiple data viewing format and decode/decompression methods.
|
||||
* Use SCAN for segmented loading, making it easy to list millions of keys.
|
||||
* Operation command execution logs.
|
||||
* Provides command-line operations.
|
||||
* Provides slow logs.
|
||||
|
||||
## Roadmap
|
||||
- [ ] Pagination and querying for List/Hash/Set/Sorted Set
|
||||
- [ ] Decode/decompression display for value of List/Hash/Set/Sorted Set
|
||||
- [ ] Real-time commands monitoring
|
||||
- [ ] Pub/Sub operations
|
||||
- [ ] Embedding Monaco Editor
|
||||
* Logs list for command operation history.
|
||||
* Provides command-line mode.
|
||||
* Provides slow logs list.
|
||||
* Segmented loading and querying for List/Hash/Set/Sorted Set.
|
||||
* Provide value decode/decompression for List/Hash/Set/Sorted Set.
|
||||
* Integrate with Monaco Editor
|
||||
* Support real-time commands monitoring.
|
||||
* Support import/export data.
|
||||
* Support publish/subscribe.
|
||||
* Support import/export connection profile.
|
||||
* Custom data encoder and decoder for value display ([Here are the instructions](https://redis.tinycraft.cc/guide/custom-decoder/)).
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -50,31 +63,53 @@ Available to download for free from [here](https://github.com/tiny-craft/tiny-rd
|
|||
> ```
|
||||
|
||||
## Build Guidelines
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go (latest version)
|
||||
* Node.js >= 16
|
||||
* NPM >= 9
|
||||
|
||||
### Install wails
|
||||
### Install Wails
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
```
|
||||
|
||||
### Clone the code
|
||||
### Pull the Code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiny-craft/tiny-rdm --depth=1
|
||||
```
|
||||
|
||||
### Build frontend
|
||||
### Build Frontend
|
||||
|
||||
```bash
|
||||
npm install --prefix ./frontend
|
||||
```
|
||||
|
||||
### Compile and run
|
||||
or
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Run
|
||||
|
||||
```bash
|
||||
wails dev
|
||||
```
|
||||
## About
|
||||
|
||||
## License
|
||||
### Wechat Official Account
|
||||
|
||||
Tiny RDM is licensed under [GNU General Public](/LICENSE) license.
|
||||
<img src="docs/images/wechat_official.png" alt="wechat" width="360" />
|
||||
|
||||
### Sponsor
|
||||
|
||||
If this project helpful for you, feel free to buy me a cup of coffee ☕️.
|
||||
|
||||
* Wechat Sponsor
|
||||
|
||||
<img src="docs/images/wechat_sponsor.jpg" alt="wechat" width="200" />
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
<div align="center">
|
||||
<a href="https://github.com/tiny-craft/tiny-rdm/"><img src="build/appicon.png" width="120"/></a>
|
||||
</div>
|
||||
<h1 align="center">Tiny RDM</h1>
|
||||
<h4 align="center"><strong><a href="/">English</a></strong> | <a href="https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md">简体中文</a> | 日本語</h4>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)
|
||||
[](https://github.com/tiny-craft/tiny-rdm/releases)
|
||||

|
||||
[](https://github.com/tiny-craft/tiny-rdm/stargazers)
|
||||
[](https://github.com/tiny-craft/tiny-rdm/fork)
|
||||
[](https://discord.gg/VTFbBMGjWh)
|
||||
[](https://twitter.com/Lykin53448)
|
||||
|
||||
<strong>Tiny RDMは、Mac、Windows、Linuxで利用可能な、モダンで軽量なクロスプラットフォームのRedisデスクトップマネージャーです。</strong>
|
||||
</div>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/dark_en.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="screenshots/light_en.png">
|
||||
<img alt="screenshot" src="screenshots/dark_en.png">
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/dark_en2.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="screenshots/light_en2.png">
|
||||
<img alt="screenshot" src="screenshots/dark_en2.png">
|
||||
</picture>
|
||||
|
||||
## 特徴
|
||||
|
||||
* 超軽量、Webview2をベースにしており、埋め込みブラウザなし([Wails](https://github.com/wailsapp/wails)に感謝)。
|
||||
* 視覚的でユーザーフレンドリーなUI、ライトとダークテーマを提供([Naive UI](https://github.com/tusen-ai/naive-ui)と[IconPark](https://iconpark.oceanengine.com)に感謝)。
|
||||
* 多言語サポート([もっと多くの言語が必要ですか?ここをクリックして貢献してください](.github/CONTRIBUTING.md))。
|
||||
* より良い接続管理:SSHトンネル/SSL/センチネルモード/クラスターモード/HTTPプロキシ/SOCKS5プロキシをサポート。
|
||||
* キー値操作の可視化、リスト、ハッシュ、文字列、セット、ソートセット、ストリームのCRUDサポート。
|
||||
* 複数のデータ表示形式とデコード/解凍方法をサポート。
|
||||
* SCANを使用してセグメント化された読み込みを行い、数百万のキーを簡単にリスト化。
|
||||
* コマンド操作履歴のログリスト。
|
||||
* コマンドラインモードを提供。
|
||||
* スローログリストを提供。
|
||||
* リスト/ハッシュ/セット/ソートセットのセグメント化された読み込みとクエリ。
|
||||
* リスト/ハッシュ/セット/ソートセットの値のデコード/解凍を提供。
|
||||
* Monaco Editorと統合。
|
||||
* リアルタイムコマンド監視をサポート。
|
||||
* データのインポート/エクスポートをサポート。
|
||||
* パブリッシュ/サブスクライブをサポート。
|
||||
* 接続プロファイルのインポート/エクスポートをサポート。
|
||||
* 値表示のためのカスタムデータエンコーダーとデコーダーをサポート([こちらが手順です](https://redis.tinycraft.cc/guide/custom-decoder/))。
|
||||
|
||||
## インストール
|
||||
|
||||
[こちら](https://github.com/tiny-craft/tiny-rdm/releases)から無料でダウンロードできます。
|
||||
|
||||
> macOSにインストール後に開けない場合は、以下のコマンドを実行してから再度開いてください:
|
||||
> ``` shell
|
||||
> sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app
|
||||
> ```
|
||||
|
||||
## ビルドガイドライン
|
||||
|
||||
### 前提条件
|
||||
|
||||
* Go(最新バージョン)
|
||||
* Node.js >= 16
|
||||
* NPM >= 9
|
||||
|
||||
### Wailsのインストール
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
```
|
||||
|
||||
### コードの取得
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiny-craft/tiny-rdm --depth=1
|
||||
```
|
||||
|
||||
### フロントエンドのビルド
|
||||
|
||||
```bash
|
||||
npm install --prefix ./frontend
|
||||
```
|
||||
|
||||
または
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### コンパイルと実行
|
||||
|
||||
```bash
|
||||
wails dev
|
||||
```
|
||||
## について
|
||||
|
||||
### Wechat公式アカウント
|
||||
|
||||
<img src="docs/images/wechat_official.png" alt="wechat" width="360" />
|
||||
|
||||
### スポンサー
|
||||
|
||||
このプロジェクトが役立つ場合は、コーヒーを一杯おごってください ☕️。
|
||||
|
||||
* Wechatスポンサー
|
||||
|
||||
<img src="docs/images/wechat_sponsor.jpg" alt="wechat" width="200" />
|
70
README_zh.md
70
README_zh.md
|
@ -1,12 +1,13 @@
|
|||
<h4 align="right"><strong><a href="/">English</a></strong> | 简体中文</h4>
|
||||
<div align="center">
|
||||
<a href="https://github.com/tiny-craft/tiny-rdm/"><img src="build/appicon.png" width="120"/></a>
|
||||
</div>
|
||||
<h1 align="center">Tiny RDM</h1>
|
||||
<h4 align="center"><strong><a href="/">English</a></strong> | 简体中文 | <a href="https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md">日本語</a></h4>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)
|
||||
[](https://github.com/tiny-craft/tiny-rdm/releases)
|
||||

|
||||
[](https://github.com/tiny-craft/tiny-rdm/stargazers)
|
||||
[](https://github.com/tiny-craft/tiny-rdm/fork)
|
||||
|
||||
|
@ -19,26 +20,33 @@
|
|||
<img alt="screenshot" src="screenshots/dark_zh.png">
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/dark_zh2.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="screenshots/light_zh2.png">
|
||||
<img alt="screenshot" src="screenshots/dark_zh2.png">
|
||||
</picture>
|
||||
|
||||
## 功能特性
|
||||
|
||||
* 极度轻量,基于Webview2,无内嵌浏览器(感谢[Wails](https://github.com/wailsapp/wails))
|
||||
* 更精美的界面,无边框窗口,提供浅色/深色主题(感谢[Naive UI](https://github.com/tusen-ai/naive-ui)
|
||||
* 界面精美易用,提供浅色/深色主题(感谢[Naive UI](https://github.com/tusen-ai/naive-ui)
|
||||
和 [IconPark](https://iconpark.oceanengine.com))
|
||||
* 多国语言支持:英文/中文([需要更多语言支持?点我贡献语言](.github/CONTRIBUTING_zh.md))
|
||||
* 更好用的连接管理:支持SSH隧道/SSL/哨兵模式/集群模式
|
||||
* 更好用的连接管理:支持SSH隧道/SSL/哨兵模式/集群模式/HTTP代理/SOCKS5代理
|
||||
* 可视化键值操作,增删查改一应俱全
|
||||
* 支持多种数据查看格式以及转码/解压方式
|
||||
* 采用SCAN分段加载,可轻松处理数百万键列表
|
||||
* 操作命令执行日志展示
|
||||
* 提供命令行操作
|
||||
* 提供慢日志展示
|
||||
|
||||
## 未来版本规划
|
||||
- [ ] List/Hash/Set/Sorted Set的分页展示和查询
|
||||
- [ ] List/Hash/Set/Sorted Set值的转码显示
|
||||
- [ ] 命令实时监控
|
||||
- [ ] 发布/订阅支持
|
||||
- [ ] 引入Monaco Editor
|
||||
* List/Hash/Set/Sorted Set的分段加载和查询
|
||||
* List/Hash/Set/Sorted Set值的转码显示
|
||||
* 内置高级编辑器Monaco Editor
|
||||
* 支持命令实时监控
|
||||
* 支持导入/导出数据
|
||||
* 支持发布订阅
|
||||
* 支持导入/导出连接配置
|
||||
* 自定义数据展示编码/解码([这是操作指引](https://redis.tinycraft.cc/zh/guide/custom-decoder/))
|
||||
|
||||
## 安装
|
||||
|
||||
|
@ -50,36 +58,68 @@
|
|||
> ```
|
||||
|
||||
## 构建项目
|
||||
|
||||
### 运行环境要求
|
||||
|
||||
* Go(最新版本)
|
||||
* Node.js >= 16
|
||||
* NPM >= 9
|
||||
|
||||
### 安装wails
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
```
|
||||
|
||||
### 拉取代码
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiny-craft/tiny-rdm --depth=1
|
||||
```
|
||||
|
||||
### 构建前端代码
|
||||
|
||||
```bash
|
||||
npm install --prefix ./frontend
|
||||
```
|
||||
|
||||
或者
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 编译运行开发版本
|
||||
|
||||
```bash
|
||||
wails dev
|
||||
```
|
||||
|
||||
## 关于
|
||||
此APP由我个人开发,也作为本人第一个开源项目的尝试,由于精力有限,可能会存在BUG或者使用体验上的问题,欢迎提交issue和PR。
|
||||
同时本人也在探索开源代码、独立开发和盈利性商业应用之间的平衡关系,欢迎有共同意向的小伙伴加入群聊探讨和交换想法。
|
||||
* QQ群:831077639
|
||||
|
||||
## 开源许可
|
||||
如果你也同为独立开发者(团队),喜欢开源,或者对Tiny Craft的相关产品感兴趣,可以关注微信公众号或者加入QQ群,探讨心得,反馈意见,交个朋友。
|
||||
|
||||
Tiny RDM 基于 [GNU General Public](/LICENSE) 开源协议.
|
||||
### 微信公众号(用户交流微信群)
|
||||
|
||||
我会不定期更新一些关于独立开发的思考和感悟,以及独立产品的介绍,欢迎扫码关注~👏
|
||||
|
||||
<img src="docs/images/wechat_official.png" alt="wechat" width="360" />
|
||||
|
||||
### B站官方账号
|
||||
|
||||
<img src="docs/images/bilibili_official.png" alt="bilibili" width="360" />
|
||||
|
||||
### 独立开发互助QQ群
|
||||
|
||||
```
|
||||
831077639
|
||||
```
|
||||
|
||||
### 赞助
|
||||
|
||||
该项目完全为爱发电,如果对你有所帮助,可以请作者喝杯咖啡 ☕️
|
||||
|
||||
* 微信赞赏
|
||||
|
||||
<img src="docs/images/wechat_sponsor.jpg" alt="wechat" width="200" />
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -2,6 +2,7 @@ package services
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
@ -41,12 +42,12 @@ func Cli() *cliService {
|
|||
}
|
||||
|
||||
func (c *cliService) runCommand(server, data string) {
|
||||
if cmds := strings.Split(data, " "); len(cmds) > 0 && len(cmds[0]) > 0 {
|
||||
if cmds := strutil.SplitCmd(data); len(cmds) > 0 && len(cmds[0]) > 0 {
|
||||
if client, err := c.getRedisClient(server); err == nil {
|
||||
args := sliceutil.Map(cmds, func(i int) any {
|
||||
return cmds[i]
|
||||
})
|
||||
if result, err := client.Do(c.ctx, args...).Result(); err == nil || err == redis.Nil {
|
||||
if result, err := client.Do(c.ctx, args...).Result(); err == nil || errors.Is(err, redis.Nil) {
|
||||
if strings.ToLower(cmds[0]) == "select" {
|
||||
// switch database
|
||||
if db, ok := strutil.AnyToInt(cmds[1]); ok {
|
||||
|
|
|
@ -5,16 +5,24 @@ import (
|
|||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/klauspost/compress/zip"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/vrischmann/userdir"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/proxy"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
. "tinyrdm/backend/storage"
|
||||
"tinyrdm/backend/types"
|
||||
_ "tinyrdm/backend/utils/proxy"
|
||||
)
|
||||
|
||||
type cmdHistoryItem struct {
|
||||
|
@ -48,11 +56,35 @@ func (c *connectionService) Start(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.Options, error) {
|
||||
var sshClient *ssh.Client
|
||||
var dialer proxy.Dialer
|
||||
var dialerErr error
|
||||
if config.Proxy.Type == 1 {
|
||||
// use system proxy
|
||||
dialer = proxy.FromEnvironment()
|
||||
} else if config.Proxy.Type == 2 {
|
||||
// use custom proxy
|
||||
proxyUrl := url.URL{
|
||||
Host: net.JoinHostPort(config.Proxy.Addr, strconv.Itoa(config.Proxy.Port)),
|
||||
}
|
||||
if len(config.Proxy.Username) > 0 {
|
||||
proxyUrl.User = url.UserPassword(config.Proxy.Username, config.Proxy.Password)
|
||||
}
|
||||
switch config.Proxy.Schema {
|
||||
case "socks5", "socks5h", "http", "https":
|
||||
proxyUrl.Scheme = config.Proxy.Schema
|
||||
default:
|
||||
proxyUrl.Scheme = "http"
|
||||
}
|
||||
if dialer, dialerErr = proxy.FromURL(&proxyUrl, proxy.Direct); dialerErr != nil {
|
||||
return nil, dialerErr
|
||||
}
|
||||
}
|
||||
|
||||
var sshConfig *ssh.ClientConfig
|
||||
var sshAddr string
|
||||
if config.SSH.Enable {
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
sshConfig = &ssh.ClientConfig{
|
||||
User: config.SSH.Username,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(config.SSH.Password)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: time.Duration(config.ConnTimeout) * time.Second,
|
||||
}
|
||||
|
@ -78,11 +110,7 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O
|
|||
return nil, errors.New("invalid login type")
|
||||
}
|
||||
|
||||
var err error
|
||||
sshClient, err = ssh.Dial("tcp", fmt.Sprintf("%s:%d", config.SSH.Addr, config.SSH.Port), sshConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshAddr = net.JoinHostPort(config.SSH.Addr, strconv.Itoa(config.SSH.Port))
|
||||
}
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
|
@ -107,29 +135,68 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O
|
|||
caCertPool.AppendCertsFromPEM(ca)
|
||||
}
|
||||
|
||||
if len(certs) <= 0 {
|
||||
return nil, errors.New("tls config error")
|
||||
}
|
||||
|
||||
tlsConfig = &tls.Config{
|
||||
RootCAs: caCertPool,
|
||||
InsecureSkipVerify: false,
|
||||
InsecureSkipVerify: config.SSL.AllowInsecure,
|
||||
Certificates: certs,
|
||||
ServerName: strings.TrimSpace(config.SSL.SNI),
|
||||
}
|
||||
}
|
||||
|
||||
option := &redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", config.Addr, config.Port),
|
||||
Username: config.Username,
|
||||
Password: config.Password,
|
||||
DialTimeout: time.Duration(config.ConnTimeout) * time.Second,
|
||||
ReadTimeout: time.Duration(config.ExecTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ExecTimeout) * time.Second,
|
||||
TLSConfig: tlsConfig,
|
||||
Username: config.Username,
|
||||
Password: config.Password,
|
||||
DialTimeout: time.Duration(config.ConnTimeout) * time.Second,
|
||||
ReadTimeout: time.Duration(config.ExecTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ExecTimeout) * time.Second,
|
||||
TLSConfig: tlsConfig,
|
||||
DisableIndentity: true,
|
||||
IdentitySuffix: "tinyrdm_",
|
||||
}
|
||||
if sshClient != nil {
|
||||
if config.Network == "unix" {
|
||||
option.Network = "unix"
|
||||
if len(config.Sock) <= 0 {
|
||||
option.Addr = "/tmp/redis.sock"
|
||||
} else {
|
||||
option.Addr = config.Sock
|
||||
}
|
||||
} else {
|
||||
option.Network = "tcp"
|
||||
port := 6379
|
||||
if config.Port > 0 {
|
||||
port = config.Port
|
||||
}
|
||||
if len(config.Addr) <= 0 {
|
||||
option.Addr = net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
} else {
|
||||
option.Addr = net.JoinHostPort(config.Addr, strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
|
||||
if len(sshAddr) > 0 {
|
||||
if dialer != nil {
|
||||
// ssh with proxy
|
||||
conn, err := dialer.Dial("tcp", sshAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc, chans, reqs, err := ssh.NewClientConn(conn, sshAddr, sshConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer = ssh.NewClient(sc, chans, reqs)
|
||||
} else {
|
||||
// ssh without proxy
|
||||
sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer = sshClient
|
||||
}
|
||||
}
|
||||
if dialer != nil {
|
||||
option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return sshClient.Dial(network, addr)
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
option.ReadTimeout = -2
|
||||
option.WriteTimeout = -2
|
||||
|
@ -156,9 +223,17 @@ func (c *connectionService) createRedisClient(config types.ConnectionConfig) (re
|
|||
if len(addr) < 2 {
|
||||
return nil, errors.New("cannot get master address")
|
||||
}
|
||||
option.Addr = fmt.Sprintf("%s:%s", addr[0], addr[1])
|
||||
option.Addr = net.JoinHostPort(addr[0], addr[1])
|
||||
option.Username = config.Sentinel.Username
|
||||
option.Password = config.Sentinel.Password
|
||||
if option.Dialer != nil {
|
||||
option.ReadTimeout = -2
|
||||
option.WriteTimeout = -2
|
||||
}
|
||||
}
|
||||
|
||||
if config.LastDB > 0 {
|
||||
option.DB = config.LastDB
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(option)
|
||||
|
@ -238,7 +313,7 @@ func (c *connectionService) ListSentinelMasters(config types.ConnectionConfig) (
|
|||
if infoMap, ok := info.(map[any]any); ok {
|
||||
retInfo = append(retInfo, map[string]string{
|
||||
"name": infoMap["name"].(string),
|
||||
"addr": fmt.Sprintf("%s:%s", infoMap["ip"].(string), infoMap["port"].(string)),
|
||||
"addr": net.JoinHostPort(infoMap["ip"].(string), infoMap["port"].(string)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -358,3 +433,202 @@ func (c *connectionService) DeleteGroup(name string, includeConn bool) (resp typ
|
|||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
// SaveLastDB save last selected database index
|
||||
func (c *connectionService) SaveLastDB(name string, db int) (resp types.JSResp) {
|
||||
param := c.conns.GetConnection(name)
|
||||
if param == nil {
|
||||
resp.Msg = "no connection named \"" + name + "\""
|
||||
return
|
||||
}
|
||||
|
||||
if param.LastDB != db {
|
||||
param.LastDB = db
|
||||
if err := c.conns.UpdateConnection(name, param.ConnectionConfig); err != nil {
|
||||
resp.Msg = "save connection fail:" + err.Error()
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
// SaveRefreshInterval save auto refresh interval
|
||||
func (c *connectionService) SaveRefreshInterval(name string, interval int) (resp types.JSResp) {
|
||||
param := c.conns.GetConnection(name)
|
||||
if param == nil {
|
||||
resp.Msg = "no connection named \"" + name + "\""
|
||||
return
|
||||
}
|
||||
if param.RefreshInterval != interval {
|
||||
param.RefreshInterval = interval
|
||||
if err := c.conns.UpdateConnection(name, param.ConnectionConfig); err != nil {
|
||||
resp.Msg = "save connection fail:" + err.Error()
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
// ExportConnections export connections to zip file
|
||||
func (c *connectionService) ExportConnections() (resp types.JSResp) {
|
||||
defaultFileName := "connections_" + time.Now().Format("20060102150405") + ".zip"
|
||||
filepath, err := runtime.SaveFileDialog(c.ctx, runtime.SaveDialogOptions{
|
||||
ShowHiddenFiles: true,
|
||||
DefaultFilename: defaultFileName,
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
Pattern: "*.zip",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
// compress the connections profile with zip
|
||||
const connectionFilename = "connections.yaml"
|
||||
inputFile, err := os.Open(path.Join(userdir.GetConfigHome(), "TinyRDM", connectionFilename))
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
defer inputFile.Close()
|
||||
|
||||
outputFile, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
defer outputFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(outputFile)
|
||||
defer zipWriter.Close()
|
||||
|
||||
headerWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: connectionFilename,
|
||||
Method: zip.Deflate,
|
||||
})
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = io.Copy(headerWriter, inputFile); err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
resp.Success = true
|
||||
resp.Data = struct {
|
||||
Path string `json:"path"`
|
||||
}{
|
||||
Path: filepath,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ImportConnections import connections from local zip file
|
||||
func (c *connectionService) ImportConnections() (resp types.JSResp) {
|
||||
filepath, err := runtime.OpenFileDialog(c.ctx, runtime.OpenDialogOptions{
|
||||
ShowHiddenFiles: true,
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
Pattern: "*.zip",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
const connectionFilename = "connections.yaml"
|
||||
zipFile, err := zip.OpenReader(filepath)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
var file *zip.File
|
||||
for _, file = range zipFile.File {
|
||||
if file.Name == connectionFilename {
|
||||
break
|
||||
}
|
||||
}
|
||||
if file != nil {
|
||||
zippedFile, err := file.Open()
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
defer zippedFile.Close()
|
||||
|
||||
outputFile, err := os.Create(path.Join(userdir.GetConfigHome(), "TinyRDM", connectionFilename))
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
defer outputFile.Close()
|
||||
|
||||
if _, err = io.Copy(outputFile, zippedFile); err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
// ParseConnectURL parse connection url string
|
||||
func (c *connectionService) ParseConnectURL(url string) (resp types.JSResp) {
|
||||
urlOpt, err := redis.ParseURL(url)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
var network, addr string
|
||||
var port int
|
||||
if urlOpt.Network == "unix" {
|
||||
network = urlOpt.Network
|
||||
addr = urlOpt.Addr
|
||||
} else {
|
||||
network = "tcp"
|
||||
addrPart := strings.Split(urlOpt.Addr, ":")
|
||||
addr = addrPart[0]
|
||||
port = 6379
|
||||
if len(addrPart) > 1 {
|
||||
port, _ = strconv.Atoi(addrPart[1])
|
||||
}
|
||||
}
|
||||
var sslServerName string
|
||||
if urlOpt.TLSConfig != nil {
|
||||
sslServerName = urlOpt.TLSConfig.ServerName
|
||||
}
|
||||
resp.Success = true
|
||||
resp.Data = struct {
|
||||
Network string `json:"network"`
|
||||
Sock string `json:"sock"`
|
||||
Addr string `json:"addr"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ConnTimeout int64 `json:"connTimeout"`
|
||||
ExecTimeout int64 `json:"execTimeout"`
|
||||
SSLServerName string `json:"sslServerName,omitempty"`
|
||||
}{
|
||||
Network: network,
|
||||
Addr: addr,
|
||||
Port: port,
|
||||
Username: urlOpt.Username,
|
||||
Password: urlOpt.Password,
|
||||
ConnTimeout: int64(urlOpt.DialTimeout.Seconds()),
|
||||
ExecTimeout: int64(urlOpt.ReadTimeout.Seconds()),
|
||||
SSLServerName: sslServerName,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -0,0 +1,187 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"tinyrdm/backend/types"
|
||||
)
|
||||
|
||||
type monitorItem struct {
|
||||
client *redis.Client
|
||||
cmd *redis.MonitorCmd
|
||||
mutex sync.Mutex
|
||||
ch chan string
|
||||
closeCh chan struct{}
|
||||
eventName string
|
||||
}
|
||||
|
||||
type monitorService struct {
|
||||
ctx context.Context
|
||||
ctxCancel context.CancelFunc
|
||||
mutex sync.Mutex
|
||||
items map[string]*monitorItem
|
||||
}
|
||||
|
||||
var monitor *monitorService
|
||||
var onceMonitor sync.Once
|
||||
|
||||
func Monitor() *monitorService {
|
||||
if monitor == nil {
|
||||
onceMonitor.Do(func() {
|
||||
monitor = &monitorService{
|
||||
items: map[string]*monitorItem{},
|
||||
}
|
||||
})
|
||||
}
|
||||
return monitor
|
||||
}
|
||||
|
||||
func (c *monitorService) getItem(server string) (*monitorItem, error) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
item, ok := c.items[server]
|
||||
if !ok {
|
||||
var err error
|
||||
conf := Connection().getConnection(server)
|
||||
if conf == nil {
|
||||
return nil, fmt.Errorf("no connection profile named: %s", server)
|
||||
}
|
||||
var uniClient redis.UniversalClient
|
||||
if uniClient, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var client *redis.Client
|
||||
if client, ok = uniClient.(*redis.Client); !ok {
|
||||
return nil, errors.New("create redis client fail")
|
||||
}
|
||||
item = &monitorItem{
|
||||
client: client,
|
||||
}
|
||||
c.items[server] = item
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (c *monitorService) Start(ctx context.Context) {
|
||||
c.ctx, c.ctxCancel = context.WithCancel(ctx)
|
||||
}
|
||||
|
||||
// StartMonitor start a monitor by server name
|
||||
func (c *monitorService) StartMonitor(server string) (resp types.JSResp) {
|
||||
item, err := c.getItem(server)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
item.ch = make(chan string)
|
||||
item.closeCh = make(chan struct{})
|
||||
item.eventName = "monitor:" + strconv.Itoa(int(time.Now().Unix()))
|
||||
item.cmd = item.client.Monitor(c.ctx, item.ch)
|
||||
item.cmd.Start()
|
||||
|
||||
go c.processMonitor(&item.mutex, item.ch, item.closeCh, item.cmd, item.eventName)
|
||||
resp.Success = true
|
||||
resp.Data = struct {
|
||||
EventName string `json:"eventName"`
|
||||
}{
|
||||
EventName: item.eventName,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *monitorService) processMonitor(mutex *sync.Mutex, ch <-chan string, closeCh <-chan struct{}, cmd *redis.MonitorCmd, eventName string) {
|
||||
lastEmitTime := time.Now().Add(-1 * time.Minute)
|
||||
cache := make([]string, 0, 1000)
|
||||
for {
|
||||
select {
|
||||
case data := <-ch:
|
||||
if data != "OK" {
|
||||
go func() {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
cache = append(cache, data)
|
||||
if time.Now().Sub(lastEmitTime) > 1*time.Second || len(cache) > 300 {
|
||||
runtime.EventsEmit(c.ctx, eventName, cache)
|
||||
cache = cache[:0:cap(cache)]
|
||||
lastEmitTime = time.Now()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
case <-closeCh:
|
||||
// monitor stopped
|
||||
cmd.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopMonitor stop monitor by server name
|
||||
func (c *monitorService) StopMonitor(server string) (resp types.JSResp) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
item, ok := c.items[server]
|
||||
if !ok || item.cmd == nil {
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
//close(item.ch)
|
||||
item.client.Close()
|
||||
close(item.closeCh)
|
||||
delete(c.items, server)
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
// StopAll stop all monitor
|
||||
func (c *monitorService) StopAll() {
|
||||
if c.ctxCancel != nil {
|
||||
c.ctxCancel()
|
||||
}
|
||||
|
||||
for server := range c.items {
|
||||
c.StopMonitor(server)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *monitorService) ExportLog(logs []string) (resp types.JSResp) {
|
||||
filepath, err := runtime.SaveFileDialog(c.ctx, runtime.SaveDialogOptions{
|
||||
ShowHiddenFiles: false,
|
||||
DefaultFilename: fmt.Sprintf("monitor_log_%s.txt", time.Now().Format("20060102150405")),
|
||||
Filters: []runtime.FileFilter{
|
||||
{Pattern: "*.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
for _, line := range logs {
|
||||
_, _ = writer.WriteString(line + "\n")
|
||||
}
|
||||
writer.Flush()
|
||||
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/adrg/sysfont"
|
||||
runtime2 "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
@ -11,6 +14,8 @@ import (
|
|||
storage2 "tinyrdm/backend/storage"
|
||||
"tinyrdm/backend/types"
|
||||
"tinyrdm/backend/utils/coll"
|
||||
convutil "tinyrdm/backend/utils/convert"
|
||||
sliceutil "tinyrdm/backend/utils/slice"
|
||||
)
|
||||
|
||||
type preferencesService struct {
|
||||
|
@ -46,6 +51,7 @@ func (p *preferencesService) SetPreferences(pf types.Preferences) (resp types.JS
|
|||
return
|
||||
}
|
||||
|
||||
p.UpdateEnv()
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
@ -96,6 +102,25 @@ func (p *preferencesService) GetFontList() (resp types.JSResp) {
|
|||
return
|
||||
}
|
||||
|
||||
func (p *preferencesService) GetBuildInDecoder() (resp types.JSResp) {
|
||||
buildinDecoder := make([]string, 0, len(convutil.BuildInDecoders))
|
||||
for name, convert := range convutil.BuildInDecoders {
|
||||
if convert.Enable() {
|
||||
buildinDecoder = append(buildinDecoder, name)
|
||||
}
|
||||
}
|
||||
resp.Data = map[string]any{
|
||||
"decoder": buildinDecoder,
|
||||
}
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
func (p *preferencesService) GetLanguage() string {
|
||||
pref := p.pref.GetPreferences()
|
||||
return pref.General.Language
|
||||
}
|
||||
|
||||
func (p *preferencesService) SetAppVersion(ver string) {
|
||||
if !strings.HasPrefix(ver, "v") {
|
||||
p.clientVersion = "v" + ver
|
||||
|
@ -112,18 +137,24 @@ func (p *preferencesService) GetAppVersion() (resp types.JSResp) {
|
|||
return
|
||||
}
|
||||
|
||||
func (p *preferencesService) SaveWindowSize(width, height int) {
|
||||
if width >= consts.MIN_WINDOW_WIDTH && height >= consts.MIN_WINDOW_HEIGHT {
|
||||
func (p *preferencesService) SaveWindowSize(width, height int, maximised bool) {
|
||||
if maximised {
|
||||
// do not update window size if maximised state
|
||||
p.UpdatePreferences(map[string]any{
|
||||
"behavior.windowWidth": width,
|
||||
"behavior.windowHeight": height,
|
||||
"behavior.windowMaximised": true,
|
||||
})
|
||||
} else if width >= consts.MIN_WINDOW_WIDTH && height >= consts.MIN_WINDOW_HEIGHT {
|
||||
p.UpdatePreferences(map[string]any{
|
||||
"behavior.windowWidth": width,
|
||||
"behavior.windowHeight": height,
|
||||
"behavior.windowMaximised": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *preferencesService) GetWindowSize() (width, height int) {
|
||||
func (p *preferencesService) GetWindowSize() (width, height int, maximised bool) {
|
||||
data := p.pref.GetPreferences()
|
||||
width, height = data.Behavior.WindowWidth, data.Behavior.WindowHeight
|
||||
width, height, maximised = data.Behavior.WindowWidth, data.Behavior.WindowHeight, data.Behavior.WindowMaximised
|
||||
if width <= 0 {
|
||||
width = consts.DEFAULT_WINDOW_WIDTH
|
||||
}
|
||||
|
@ -133,6 +164,38 @@ func (p *preferencesService) GetWindowSize() (width, height int) {
|
|||
return
|
||||
}
|
||||
|
||||
func (p *preferencesService) GetWindowPosition(ctx context.Context) (x, y int) {
|
||||
data := p.pref.GetPreferences()
|
||||
x, y = data.Behavior.WindowPosX, data.Behavior.WindowPosY
|
||||
width, height := data.Behavior.WindowWidth, data.Behavior.WindowHeight
|
||||
var screenWidth, screenHeight int
|
||||
if screens, err := runtime2.ScreenGetAll(ctx); err == nil {
|
||||
for _, screen := range screens {
|
||||
if screen.IsCurrent {
|
||||
screenWidth, screenHeight = screen.Size.Width, screen.Size.Height
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if screenWidth <= 0 || screenHeight <= 0 {
|
||||
screenWidth, screenHeight = consts.DEFAULT_WINDOW_WIDTH, consts.DEFAULT_WINDOW_HEIGHT
|
||||
}
|
||||
if x <= 0 || x+width > screenWidth || y <= 0 || y+height > screenHeight {
|
||||
// out of screen, reset to center
|
||||
x, y = (screenWidth-width)/2, (screenHeight-height)/2
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *preferencesService) SaveWindowPosition(x, y int) {
|
||||
if x > 0 || y > 0 {
|
||||
p.UpdatePreferences(map[string]any{
|
||||
"behavior.windowPosX": x,
|
||||
"behavior.windowPosY": y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *preferencesService) GetScanSize() int {
|
||||
data := p.pref.GetPreferences()
|
||||
size := data.General.ScanSize
|
||||
|
@ -142,22 +205,48 @@ func (p *preferencesService) GetScanSize() int {
|
|||
return size
|
||||
}
|
||||
|
||||
type latestRelease struct {
|
||||
Name string `json:"name"`
|
||||
TagName string `json:"tag_name"`
|
||||
Url string `json:"url"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
func (p *preferencesService) GetDecoder() []convutil.CmdConvert {
|
||||
data := p.pref.GetPreferences()
|
||||
return sliceutil.FilterMap(data.Decoder, func(i int) (convutil.CmdConvert, bool) {
|
||||
//if !data.Decoder[i].Enable {
|
||||
// return convutil.CmdConvert{}, false
|
||||
//}
|
||||
return convutil.CmdConvert{
|
||||
Name: data.Decoder[i].Name,
|
||||
Auto: data.Decoder[i].Auto,
|
||||
DecodePath: data.Decoder[i].DecodePath,
|
||||
DecodeArgs: data.Decoder[i].DecodeArgs,
|
||||
EncodePath: data.Decoder[i].EncodePath,
|
||||
EncodeArgs: data.Decoder[i].EncodeArgs,
|
||||
}, true
|
||||
})
|
||||
}
|
||||
|
||||
type sponsorItem struct {
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Region []string `json:"region"`
|
||||
}
|
||||
|
||||
type upgradeInfo struct {
|
||||
Version string `json:"version"`
|
||||
Changelog map[string]string `json:"changelog"`
|
||||
Description map[string]string `json:"description"`
|
||||
DownloadURl map[string]string `json:"download_url"`
|
||||
DownloadPage map[string]string `json:"download_page"`
|
||||
Sponsor []sponsorItem `json:"sponsor,omitempty"`
|
||||
}
|
||||
|
||||
func (p *preferencesService) CheckForUpdate() (resp types.JSResp) {
|
||||
// request latest version
|
||||
res, err := http.Get("https://api.github.com/repos/tiny-craft/tiny-rdm/releases/latest")
|
||||
//res, err := http.Get("https://api.github.com/repos/tiny-craft/tiny-rdm/releases/latest")
|
||||
res, err := http.Get("https://redis.tinycraft.cc/client_version.json")
|
||||
if err != nil || res.StatusCode != http.StatusOK {
|
||||
resp.Msg = "network error"
|
||||
return
|
||||
}
|
||||
|
||||
var respObj latestRelease
|
||||
var respObj upgradeInfo
|
||||
err = json.NewDecoder(res.Body).Decode(&respObj)
|
||||
if err != nil {
|
||||
resp.Msg = "invalid content"
|
||||
|
@ -167,9 +256,20 @@ func (p *preferencesService) CheckForUpdate() (resp types.JSResp) {
|
|||
// compare with current version
|
||||
resp.Success = true
|
||||
resp.Data = map[string]any{
|
||||
"version": p.clientVersion,
|
||||
"latest": respObj.TagName,
|
||||
"page_url": respObj.HtmlUrl,
|
||||
"version": p.clientVersion,
|
||||
"latest": respObj.Version,
|
||||
"description": respObj.Description,
|
||||
"download_page": respObj.DownloadPage,
|
||||
"sponsor": respObj.Sponsor,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateEnv Update System Environment
|
||||
func (p *preferencesService) UpdateEnv() {
|
||||
if p.GetLanguage() == "zh" {
|
||||
os.Setenv("LANG", "zh_CN.UTF-8")
|
||||
} else {
|
||||
os.Unsetenv("LANG")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"tinyrdm/backend/types"
|
||||
)
|
||||
|
||||
type pubsubItem struct {
|
||||
client redis.UniversalClient
|
||||
pubsub *redis.PubSub
|
||||
mutex sync.Mutex
|
||||
closeCh chan struct{}
|
||||
eventName string
|
||||
}
|
||||
|
||||
type subMessage struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Channel string `json:"channel"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type pubsubService struct {
|
||||
ctx context.Context
|
||||
ctxCancel context.CancelFunc
|
||||
mutex sync.Mutex
|
||||
items map[string]*pubsubItem
|
||||
}
|
||||
|
||||
var pubsub *pubsubService
|
||||
var oncePubsub sync.Once
|
||||
|
||||
func Pubsub() *pubsubService {
|
||||
if pubsub == nil {
|
||||
oncePubsub.Do(func() {
|
||||
pubsub = &pubsubService{
|
||||
items: map[string]*pubsubItem{},
|
||||
}
|
||||
})
|
||||
}
|
||||
return pubsub
|
||||
}
|
||||
|
||||
func (p *pubsubService) getItem(server string) (*pubsubItem, error) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
item, ok := p.items[server]
|
||||
if !ok {
|
||||
var err error
|
||||
conf := Connection().getConnection(server)
|
||||
if conf == nil {
|
||||
return nil, fmt.Errorf("no connection profile named: %s", server)
|
||||
}
|
||||
var uniClient redis.UniversalClient
|
||||
if uniClient, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item = &pubsubItem{
|
||||
client: uniClient,
|
||||
}
|
||||
p.items[server] = item
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (p *pubsubService) Start(ctx context.Context) {
|
||||
p.ctx, p.ctxCancel = context.WithCancel(ctx)
|
||||
}
|
||||
|
||||
// Publish publish message to channel
|
||||
func (p *pubsubService) Publish(server, channel, payload string) (resp types.JSResp) {
|
||||
rdb, err := Browser().getRedisClient(server, -1)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
var received int64
|
||||
received, err = rdb.client.Publish(p.ctx, channel, payload).Result()
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
resp.Success = true
|
||||
resp.Data = struct {
|
||||
Received int64 `json:"received"`
|
||||
}{
|
||||
Received: received,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// StartSubscribe start to subscribe a channel
|
||||
func (p *pubsubService) StartSubscribe(server string) (resp types.JSResp) {
|
||||
item, err := p.getItem(server)
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
item.closeCh = make(chan struct{})
|
||||
item.eventName = "sub:" + strconv.Itoa(int(time.Now().Unix()))
|
||||
item.pubsub = item.client.PSubscribe(p.ctx, "*")
|
||||
|
||||
go p.processSubscribe(&item.mutex, item.pubsub.Channel(), item.closeCh, item.eventName)
|
||||
resp.Success = true
|
||||
resp.Data = struct {
|
||||
EventName string `json:"eventName"`
|
||||
}{
|
||||
EventName: item.eventName,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *pubsubService) processSubscribe(mutex *sync.Mutex, ch <-chan *redis.Message, closeCh <-chan struct{}, eventName string) {
|
||||
lastEmitTime := time.Now().Add(-1 * time.Minute)
|
||||
cache := make([]subMessage, 0, 1000)
|
||||
for {
|
||||
select {
|
||||
case data := <-ch:
|
||||
go func() {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
cache = append(cache, subMessage{
|
||||
Timestamp: timestamp,
|
||||
Channel: data.Channel,
|
||||
Message: data.Payload,
|
||||
})
|
||||
if time.Now().Sub(lastEmitTime) > 300*time.Millisecond || len(cache) > 300 {
|
||||
runtime.EventsEmit(p.ctx, eventName, cache)
|
||||
cache = cache[:0:cap(cache)]
|
||||
lastEmitTime = time.Now()
|
||||
}
|
||||
}()
|
||||
|
||||
case <-closeCh:
|
||||
// subscribe stopped
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopSubscribe stop subscribe by server name
|
||||
func (p *pubsubService) StopSubscribe(server string) (resp types.JSResp) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
item, ok := p.items[server]
|
||||
if !ok || item.pubsub == nil {
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
//item.pubsub.Unsubscribe(p.ctx, "*")
|
||||
item.pubsub.Close()
|
||||
close(item.closeCh)
|
||||
delete(p.items, server)
|
||||
resp.Success = true
|
||||
return
|
||||
}
|
||||
|
||||
// StopAll stop all subscribe
|
||||
func (p *pubsubService) StopAll() {
|
||||
if p.ctxCancel != nil {
|
||||
p.ctxCancel()
|
||||
}
|
||||
|
||||
for server := range p.items {
|
||||
p.StopSubscribe(server)
|
||||
}
|
||||
}
|
|
@ -3,15 +3,17 @@ package services
|
|||
import (
|
||||
"context"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"log"
|
||||
runtime2 "runtime"
|
||||
"sync"
|
||||
"time"
|
||||
"tinyrdm/backend/consts"
|
||||
"tinyrdm/backend/types"
|
||||
sliceutil "tinyrdm/backend/utils/slice"
|
||||
)
|
||||
|
||||
type systemService struct {
|
||||
ctx context.Context
|
||||
ctx context.Context
|
||||
appVersion string
|
||||
}
|
||||
|
||||
var system *systemService
|
||||
|
@ -20,15 +22,18 @@ var onceSystem sync.Once
|
|||
func System() *systemService {
|
||||
if system == nil {
|
||||
onceSystem.Do(func() {
|
||||
system = &systemService{}
|
||||
system = &systemService{
|
||||
appVersion: "0.0.0",
|
||||
}
|
||||
go system.loopWindowEvent()
|
||||
})
|
||||
}
|
||||
return system
|
||||
}
|
||||
|
||||
func (s *systemService) Start(ctx context.Context) {
|
||||
func (s *systemService) Start(ctx context.Context, version string) {
|
||||
s.ctx = ctx
|
||||
s.appVersion = version
|
||||
|
||||
// maximize the window if screen size is lower than the minimum window size
|
||||
if screen, err := runtime.ScreenGetAll(ctx); err == nil && len(screen) > 0 {
|
||||
|
@ -43,14 +48,57 @@ func (s *systemService) Start(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *systemService) Info() (resp types.JSResp) {
|
||||
resp.Success = true
|
||||
resp.Data = struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
}{
|
||||
OS: runtime2.GOOS,
|
||||
Arch: runtime2.GOARCH,
|
||||
Version: s.appVersion,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SelectFile open file dialog to select a file
|
||||
func (s *systemService) SelectFile(title string) (resp types.JSResp) {
|
||||
func (s *systemService) SelectFile(title string, extensions []string) (resp types.JSResp) {
|
||||
filters := sliceutil.Map(extensions, func(i int) runtime.FileFilter {
|
||||
return runtime.FileFilter{
|
||||
Pattern: "*." + extensions[i],
|
||||
}
|
||||
})
|
||||
filepath, err := runtime.OpenFileDialog(s.ctx, runtime.OpenDialogOptions{
|
||||
Title: title,
|
||||
ShowHiddenFiles: true,
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
resp.Success = true
|
||||
resp.Data = map[string]any{
|
||||
"path": filepath,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SaveFile open file dialog to save a file
|
||||
func (s *systemService) SaveFile(title string, defaultName string, extensions []string) (resp types.JSResp) {
|
||||
filters := sliceutil.Map(extensions, func(i int) runtime.FileFilter {
|
||||
return runtime.FileFilter{
|
||||
Pattern: "*." + extensions[i],
|
||||
}
|
||||
})
|
||||
filepath, err := runtime.SaveFileDialog(s.ctx, runtime.SaveDialogOptions{
|
||||
Title: title,
|
||||
ShowHiddenFiles: true,
|
||||
DefaultFilename: defaultName,
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
resp.Msg = err.Error()
|
||||
return
|
||||
}
|
||||
|
@ -110,8 +158,8 @@ func (s *systemService) loopWindowEvent() {
|
|||
})
|
||||
|
||||
if !fullscreen && !minimised {
|
||||
// save window size
|
||||
Preferences().SaveWindowSize(width, height)
|
||||
// save window size and position
|
||||
Preferences().SaveWindowSize(width, height, maximised)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ package storage
|
|||
import (
|
||||
"errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
"slices"
|
||||
"sync"
|
||||
"tinyrdm/backend/consts"
|
||||
"tinyrdm/backend/types"
|
||||
sliceutil "tinyrdm/backend/utils/slice"
|
||||
)
|
||||
|
||||
type ConnectionsStorage struct {
|
||||
|
@ -26,19 +26,21 @@ func (c *ConnectionsStorage) defaultConnections() types.Connections {
|
|||
|
||||
func (c *ConnectionsStorage) defaultConnectionItem() types.ConnectionConfig {
|
||||
return types.ConnectionConfig{
|
||||
Name: "",
|
||||
Addr: "127.0.0.1",
|
||||
Port: 6379,
|
||||
Username: "",
|
||||
Password: "",
|
||||
DefaultFilter: "*",
|
||||
KeySeparator: ":",
|
||||
ConnTimeout: 60,
|
||||
ExecTimeout: 60,
|
||||
DBFilterType: "none",
|
||||
DBFilterList: []int{},
|
||||
LoadSize: consts.DEFAULT_LOAD_SIZE,
|
||||
MarkColor: "",
|
||||
Name: "",
|
||||
Network: "tcp",
|
||||
Addr: "127.0.0.1",
|
||||
Port: 6379,
|
||||
Username: "",
|
||||
Password: "",
|
||||
DefaultFilter: "*",
|
||||
KeySeparator: ":",
|
||||
ConnTimeout: 60,
|
||||
ExecTimeout: 60,
|
||||
DBFilterType: "none",
|
||||
DBFilterList: []int{},
|
||||
LoadSize: consts.DEFAULT_LOAD_SIZE,
|
||||
MarkColor: "",
|
||||
RefreshInterval: 5,
|
||||
Sentinel: types.ConnectionSentinel{
|
||||
Master: "mymaster",
|
||||
},
|
||||
|
@ -47,8 +49,8 @@ func (c *ConnectionsStorage) defaultConnectionItem() types.ConnectionConfig {
|
|||
|
||||
func (c *ConnectionsStorage) getConnections() (ret types.Connections) {
|
||||
b, err := c.storage.Load()
|
||||
ret = c.defaultConnections()
|
||||
if err != nil {
|
||||
ret = c.defaultConnections()
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -254,10 +256,10 @@ func (c *ConnectionsStorage) SaveSortedConnection(sortedConns types.Connections)
|
|||
|
||||
conns := c.GetConnectionsFlat()
|
||||
takeConn := func(name string) (types.Connection, bool) {
|
||||
idx, ok := sliceutil.Find(conns, func(i int) bool {
|
||||
return conns[i].Name == name
|
||||
idx := slices.IndexFunc(conns, func(connection types.Connection) bool {
|
||||
return connection.Name == name
|
||||
})
|
||||
if ok {
|
||||
if idx >= 0 {
|
||||
ret := conns[idx]
|
||||
conns = append(conns[:idx], conns[idx+1:]...)
|
||||
return ret, true
|
||||
|
|
|
@ -17,8 +17,10 @@ type PreferencesStorage struct {
|
|||
}
|
||||
|
||||
func NewPreferences() *PreferencesStorage {
|
||||
storage := NewLocalStore("preferences.yaml")
|
||||
log.Printf("preferences path: %s\n", storage.ConfPath)
|
||||
return &PreferencesStorage{
|
||||
storage: NewLocalStore("preferences.yaml"),
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,9 +29,9 @@ func (p *PreferencesStorage) DefaultPreferences() types.Preferences {
|
|||
}
|
||||
|
||||
func (p *PreferencesStorage) getPreferences() (ret types.Preferences) {
|
||||
ret = p.DefaultPreferences()
|
||||
b, err := p.storage.Load()
|
||||
if err != nil {
|
||||
ret = p.DefaultPreferences()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -3,25 +3,31 @@ package types
|
|||
type ConnectionCategory int
|
||||
|
||||
type ConnectionConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Group string `json:"group,omitempty" yaml:"-"`
|
||||
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
|
||||
Port int `json:"port,omitempty" yaml:"port,omitempty"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
Password string `json:"password,omitempty" yaml:"password,omitempty"`
|
||||
DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"`
|
||||
KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"`
|
||||
ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"`
|
||||
ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"`
|
||||
DBFilterType string `json:"dbFilterType" yaml:"db_filter_type,omitempty"`
|
||||
DBFilterList []int `json:"dbFilterList" yaml:"db_filter_list,omitempty"`
|
||||
KeyView int `json:"keyView,omitempty" yaml:"key_view,omitempty"`
|
||||
LoadSize int `json:"loadSize,omitempty" yaml:"load_size,omitempty"`
|
||||
MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"`
|
||||
SSL ConnectionSSL `json:"ssl,omitempty" yaml:"ssl,omitempty"`
|
||||
SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,omitempty"`
|
||||
Sentinel ConnectionSentinel `json:"sentinel,omitempty" yaml:"sentinel,omitempty"`
|
||||
Cluster ConnectionCluster `json:"cluster,omitempty" yaml:"cluster,omitempty"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Group string `json:"group,omitempty" yaml:"-"`
|
||||
LastDB int `json:"lastDB" yaml:"last_db"`
|
||||
Network string `json:"network,omitempty" yaml:"network,omitempty"`
|
||||
Sock string `json:"sock,omitempty" yaml:"sock,omitempty"`
|
||||
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
|
||||
Port int `json:"port,omitempty" yaml:"port,omitempty"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
Password string `json:"password,omitempty" yaml:"password,omitempty"`
|
||||
DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"`
|
||||
KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"`
|
||||
ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"`
|
||||
ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"`
|
||||
DBFilterType string `json:"dbFilterType" yaml:"db_filter_type,omitempty"`
|
||||
DBFilterList []int `json:"dbFilterList" yaml:"db_filter_list,omitempty"`
|
||||
KeyView int `json:"keyView,omitempty" yaml:"key_view,omitempty"`
|
||||
LoadSize int `json:"loadSize,omitempty" yaml:"load_size,omitempty"`
|
||||
MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"`
|
||||
RefreshInterval int `json:"refreshInterval,omitempty" yaml:"refresh_interval,omitempty"`
|
||||
Alias map[int]string `json:"alias,omitempty" yaml:"alias,omitempty"`
|
||||
SSL ConnectionSSL `json:"ssl,omitempty" yaml:"ssl,omitempty"`
|
||||
SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,omitempty"`
|
||||
Sentinel ConnectionSentinel `json:"sentinel,omitempty" yaml:"sentinel,omitempty"`
|
||||
Cluster ConnectionCluster `json:"cluster,omitempty" yaml:"cluster,omitempty"`
|
||||
Proxy ConnectionProxy `json:"proxy,omitempty" yaml:"proxy,omitempty"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
|
@ -34,17 +40,20 @@ type Connections []Connection
|
|||
|
||||
type ConnectionDB struct {
|
||||
Name string `json:"name"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Index int `json:"index"`
|
||||
Keys int `json:"keys"`
|
||||
MaxKeys int `json:"maxKeys"`
|
||||
Expires int `json:"expires,omitempty"`
|
||||
AvgTTL int `json:"avgTtl,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionSSL struct {
|
||||
Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"`
|
||||
KeyFile string `json:"keyFile,omitempty" yaml:"keyFile,omitempty"`
|
||||
CertFile string `json:"certFile,omitempty" yaml:"certFile,omitempty"`
|
||||
CAFile string `json:"caFile,omitempty" yaml:"caFile,omitempty"`
|
||||
Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"`
|
||||
KeyFile string `json:"keyFile,omitempty" yaml:"keyfile,omitempty"`
|
||||
CertFile string `json:"certFile,omitempty" yaml:"certfile,omitempty"`
|
||||
CAFile string `json:"caFile,omitempty" yaml:"cafile,omitempty"`
|
||||
AllowInsecure bool `json:"allowInsecure,omitempty" yaml:"allow_insecure,omitempty"`
|
||||
SNI string `json:"sni,omitempty" yaml:"sni,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionSSH struct {
|
||||
|
@ -68,3 +77,12 @@ type ConnectionSentinel struct {
|
|||
type ConnectionCluster struct {
|
||||
Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionProxy struct {
|
||||
Type int `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
Schema string `json:"schema,omitempty" yaml:"schema,omitempty"`
|
||||
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
|
||||
Port int `json:"port,omitempty" yaml:"port,omitempty"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
Password string `json:"password,omitempty" yaml:"password,omitempty"`
|
||||
}
|
||||
|
|
|
@ -14,9 +14,9 @@ type KeySummaryParam struct {
|
|||
|
||||
type KeySummary struct {
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"`
|
||||
Size int64 `json:"size"`
|
||||
Length int64 `json:"length"`
|
||||
TTL int64 `json:"ttl,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Length int64 `json:"length,omitempty"`
|
||||
}
|
||||
|
||||
type KeyDetailParam struct {
|
||||
|
@ -31,13 +31,14 @@ type KeyDetailParam struct {
|
|||
}
|
||||
|
||||
type KeyDetail struct {
|
||||
Value any `json:"value"`
|
||||
Length int64 `json:"length,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Decode string `json:"decode,omitempty"`
|
||||
Match string `json:"match,omitempty"`
|
||||
Reset bool `json:"reset"`
|
||||
End bool `json:"end"`
|
||||
Value any `json:"value"`
|
||||
KeyType string `json:"key_type"`
|
||||
Length int64 `json:"length,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Decode string `json:"decode,omitempty"`
|
||||
Match string `json:"match,omitempty"`
|
||||
Reset bool `json:"reset"`
|
||||
End bool `json:"end"`
|
||||
}
|
||||
|
||||
type SetKeyParam struct {
|
||||
|
@ -55,7 +56,7 @@ type SetListParam struct {
|
|||
Server string `json:"server"`
|
||||
DB int `json:"db"`
|
||||
Key any `json:"key"`
|
||||
Index int64 `json:"index"`
|
||||
Index int `json:"index"`
|
||||
Value any `json:"value"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Decode string `json:"decode,omitempty"`
|
||||
|
@ -100,3 +101,12 @@ type SetZSetParam struct {
|
|||
RetFormat string `json:"retFormat,omitempty"`
|
||||
RetDecode string `json:"retDecode,omitempty"`
|
||||
}
|
||||
|
||||
type GetHashParam struct {
|
||||
Server string `json:"server"`
|
||||
DB int `json:"db"`
|
||||
Key any `json:"key"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Decode string `json:"decode,omitempty"`
|
||||
}
|
||||
|
|
|
@ -3,9 +3,11 @@ package types
|
|||
import "tinyrdm/backend/consts"
|
||||
|
||||
type Preferences struct {
|
||||
Behavior PreferencesBehavior `json:"behavior" yaml:"behavior"`
|
||||
General PreferencesGeneral `json:"general" yaml:"general"`
|
||||
Editor PreferencesEditor `json:"editor" yaml:"editor"`
|
||||
Behavior PreferencesBehavior `json:"behavior" yaml:"behavior"`
|
||||
General PreferencesGeneral `json:"general" yaml:"general"`
|
||||
Editor PreferencesEditor `json:"editor" yaml:"editor"`
|
||||
Cli PreferencesCli `json:"cli" yaml:"cli"`
|
||||
Decoder []PreferencesDecoder `json:"decoder" yaml:"decoder,omitempty"`
|
||||
}
|
||||
|
||||
func NewPreferences() Preferences {
|
||||
|
@ -16,37 +18,78 @@ func NewPreferences() Preferences {
|
|||
WindowHeight: consts.DEFAULT_WINDOW_HEIGHT,
|
||||
},
|
||||
General: PreferencesGeneral{
|
||||
Theme: "auto",
|
||||
Language: "auto",
|
||||
FontSize: consts.DEFAULT_FONT_SIZE,
|
||||
ScanSize: consts.DEFAULT_SCAN_SIZE,
|
||||
CheckUpdate: true,
|
||||
Theme: "auto",
|
||||
Language: "auto",
|
||||
FontSize: consts.DEFAULT_FONT_SIZE,
|
||||
ScanSize: consts.DEFAULT_SCAN_SIZE,
|
||||
KeyIconStyle: 0,
|
||||
CheckUpdate: true,
|
||||
AllowTrack: true,
|
||||
},
|
||||
Editor: PreferencesEditor{
|
||||
FontSize: consts.DEFAULT_FONT_SIZE,
|
||||
FontSize: consts.DEFAULT_FONT_SIZE,
|
||||
ShowLineNum: true,
|
||||
ShowFolding: true,
|
||||
DropText: true,
|
||||
Links: true,
|
||||
EntryTextAlign: 0,
|
||||
},
|
||||
Cli: PreferencesCli{
|
||||
FontSize: consts.DEFAULT_FONT_SIZE,
|
||||
CursorStyle: "block",
|
||||
},
|
||||
Decoder: []PreferencesDecoder{},
|
||||
}
|
||||
}
|
||||
|
||||
type PreferencesBehavior struct {
|
||||
AsideWidth int `json:"asideWidth" yaml:"aside_width"`
|
||||
WindowWidth int `json:"windowWidth" yaml:"window_width"`
|
||||
WindowHeight int `json:"windowHeight" yaml:"window_height"`
|
||||
Welcomed bool `json:"welcomed" yaml:"welcomed"`
|
||||
AsideWidth int `json:"asideWidth" yaml:"aside_width"`
|
||||
WindowWidth int `json:"windowWidth" yaml:"window_width"`
|
||||
WindowHeight int `json:"windowHeight" yaml:"window_height"`
|
||||
WindowMaximised bool `json:"windowMaximised" yaml:"window_maximised"`
|
||||
WindowPosX int `json:"windowPosX" yaml:"window_pos_x"`
|
||||
WindowPosY int `json:"windowPosY" yaml:"window_pos_y"`
|
||||
}
|
||||
|
||||
type PreferencesGeneral struct {
|
||||
Theme string `json:"theme" yaml:"theme"`
|
||||
Language string `json:"language" yaml:"language"`
|
||||
Font string `json:"font" yaml:"font,omitempty"`
|
||||
FontSize int `json:"fontSize" yaml:"font_size"`
|
||||
ScanSize int `json:"scanSize" yaml:"scan_size"`
|
||||
UseSysProxy bool `json:"useSysProxy" yaml:"use_sys_proxy,omitempty"`
|
||||
UseSysProxyHttp bool `json:"useSysProxyHttp" yaml:"use_sys_proxy_http,omitempty"`
|
||||
CheckUpdate bool `json:"checkUpdate" yaml:"check_update"`
|
||||
SkipVersion string `json:"skipVersion" yaml:"skip_version,omitempty"`
|
||||
Theme string `json:"theme" yaml:"theme"`
|
||||
Language string `json:"language" yaml:"language"`
|
||||
Font string `json:"font" yaml:"font,omitempty"`
|
||||
FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"`
|
||||
FontSize int `json:"fontSize" yaml:"font_size"`
|
||||
ScanSize int `json:"scanSize" yaml:"scan_size"`
|
||||
KeyIconStyle int `json:"keyIconStyle" yaml:"key_icon_style"`
|
||||
UseSysProxy bool `json:"useSysProxy" yaml:"use_sys_proxy,omitempty"`
|
||||
UseSysProxyHttp bool `json:"useSysProxyHttp" yaml:"use_sys_proxy_http,omitempty"`
|
||||
CheckUpdate bool `json:"checkUpdate" yaml:"check_update"`
|
||||
SkipVersion string `json:"skipVersion" yaml:"skip_version,omitempty"`
|
||||
AllowTrack bool `json:"allowTrack" yaml:"allow_track"`
|
||||
}
|
||||
|
||||
type PreferencesEditor struct {
|
||||
Font string `json:"font" yaml:"font,omitempty"`
|
||||
FontSize int `json:"fontSize" yaml:"font_size"`
|
||||
Font string `json:"font" yaml:"font,omitempty"`
|
||||
FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"`
|
||||
FontSize int `json:"fontSize" yaml:"font_size"`
|
||||
ShowLineNum bool `json:"showLineNum" yaml:"show_line_num"`
|
||||
ShowFolding bool `json:"showFolding" yaml:"show_folding"`
|
||||
DropText bool `json:"dropText" yaml:"drop_text"`
|
||||
Links bool `json:"links" yaml:"links"`
|
||||
EntryTextAlign int `json:"entryTextAlign" yaml:"entry_text_align"`
|
||||
}
|
||||
|
||||
type PreferencesCli struct {
|
||||
FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"`
|
||||
FontSize int `json:"fontSize" yaml:"font_size"`
|
||||
CursorStyle string `json:"cursorStyle" yaml:"cursor_style,omitempty"`
|
||||
}
|
||||
|
||||
type PreferencesDecoder struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Enable bool `json:"enable" yaml:"enable"`
|
||||
Auto bool `json:"auto" yaml:"auto"`
|
||||
DecodePath string `json:"decodePath" yaml:"decode_path"`
|
||||
DecodeArgs []string `json:"decodeArgs" yaml:"decode_args,omitempty"`
|
||||
EncodePath string `json:"encodePath" yaml:"encode_path"`
|
||||
EncodeArgs []string `json:"encodeArgs" yaml:"encode_args,omitempty"`
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
package types
|
||||
|
||||
type ListEntryItem struct {
|
||||
Index int `json:"index"`
|
||||
Value any `json:"v"`
|
||||
DisplayValue string `json:"dv,omitempty"`
|
||||
}
|
||||
|
||||
type ListReplaceItem struct {
|
||||
Index int64 `json:"index"`
|
||||
Index int `json:"index"`
|
||||
Value any `json:"v,omitempty"`
|
||||
DisplayValue string `json:"dv,omitempty"`
|
||||
}
|
||||
|
@ -31,7 +32,8 @@ type SetEntryItem struct {
|
|||
|
||||
type ZSetEntryItem struct {
|
||||
Score float64 `json:"s"`
|
||||
Value string `json:"v"`
|
||||
ScoreStr string `json:"ss,omitempty"`
|
||||
Value any `json:"v"`
|
||||
DisplayValue string `json:"dv,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,9 @@ package types
|
|||
|
||||
const FORMAT_RAW = "Raw"
|
||||
const FORMAT_JSON = "JSON"
|
||||
const FORMAT_UNICODE_JSON = "Unicode JSON"
|
||||
const FORMAT_YAML = "YAML"
|
||||
const FORMAT_XML = "XML"
|
||||
const FORMAT_HEX = "Hex"
|
||||
const FORMAT_BINARY = "Binary"
|
||||
|
||||
|
@ -10,4 +13,8 @@ const DECODE_BASE64 = "Base64"
|
|||
const DECODE_GZIP = "GZip"
|
||||
const DECODE_DEFLATE = "Deflate"
|
||||
const DECODE_ZSTD = "ZStd"
|
||||
const DECODE_LZ4 = "LZ4"
|
||||
const DECODE_BROTLI = "Brotli"
|
||||
const DECODE_MSGPACK = "Msgpack"
|
||||
const DECODE_PHP = "PHP"
|
||||
const DECODE_PICKLE = "Pickle"
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
strutil "tinyrdm/backend/utils/string"
|
||||
)
|
||||
|
||||
type Base64Convert struct{}
|
||||
|
||||
func (Base64Convert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (Base64Convert) Encode(str string) (string, bool) {
|
||||
return base64.StdEncoding.EncodeToString([]byte(str)), true
|
||||
}
|
||||
|
||||
func (Base64Convert) Decode(str string) (string, bool) {
|
||||
if decodedStr, err := base64.StdEncoding.DecodeString(str); err == nil {
|
||||
if s := string(decodedStr); !strutil.ContainsBinary(s) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BinaryConvert struct{}
|
||||
|
||||
func (BinaryConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (BinaryConvert) Encode(str string) (string, bool) {
|
||||
var result strings.Builder
|
||||
total := len(str)
|
||||
for i := 0; i < total; i += 8 {
|
||||
b, _ := strconv.ParseInt(str[i:i+8], 2, 8)
|
||||
result.WriteByte(byte(b))
|
||||
}
|
||||
return result.String(), true
|
||||
}
|
||||
|
||||
func (BinaryConvert) Decode(str string) (string, bool) {
|
||||
var binary strings.Builder
|
||||
for _, char := range []byte(str) {
|
||||
binary.WriteString(fmt.Sprintf("%08b", int(char)))
|
||||
}
|
||||
return binary.String(), true
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/andybalholm/brotli"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BrotliConvert struct{}
|
||||
|
||||
func (BrotliConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (BrotliConvert) Encode(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := brotli.NewWriter(&buf)
|
||||
if _, err := writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
if brotliStr, err := compress([]byte(str)); err == nil {
|
||||
return brotliStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (BrotliConvert) Decode(str string) (string, bool) {
|
||||
reader := brotli.NewReader(strings.NewReader(str))
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
sliceutil "tinyrdm/backend/utils/slice"
|
||||
)
|
||||
|
||||
type CmdConvert struct {
|
||||
Name string
|
||||
Auto bool
|
||||
DecodePath string
|
||||
DecodeArgs []string
|
||||
EncodePath string
|
||||
EncodeArgs []string
|
||||
}
|
||||
|
||||
const replaceholder = "{VALUE}"
|
||||
|
||||
func (c CmdConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c CmdConvert) Encode(str string) (string, bool) {
|
||||
base64Content := base64.StdEncoding.EncodeToString([]byte(str))
|
||||
var containHolder bool
|
||||
args := sliceutil.Map(c.EncodeArgs, func(i int) string {
|
||||
arg := strings.TrimSpace(c.EncodeArgs[i])
|
||||
if strings.Contains(arg, replaceholder) {
|
||||
arg = strings.ReplaceAll(arg, replaceholder, base64Content)
|
||||
containHolder = true
|
||||
}
|
||||
return arg
|
||||
})
|
||||
if len(args) <= 0 || !containHolder {
|
||||
args = append(args, base64Content)
|
||||
}
|
||||
output, err := runCommand(c.EncodePath, args...)
|
||||
if err != nil || len(output) <= 0 || string(output) == "[RDM-ERROR]" {
|
||||
return str, false
|
||||
}
|
||||
|
||||
outputContent := make([]byte, base64.StdEncoding.DecodedLen(len(output)))
|
||||
n, err := base64.StdEncoding.Decode(outputContent, output)
|
||||
if err != nil {
|
||||
return str, false
|
||||
}
|
||||
return string(outputContent[:n]), true
|
||||
}
|
||||
|
||||
func (c CmdConvert) Decode(str string) (string, bool) {
|
||||
base64Content := base64.StdEncoding.EncodeToString([]byte(str))
|
||||
var containHolder bool
|
||||
args := sliceutil.Map(c.DecodeArgs, func(i int) string {
|
||||
arg := strings.TrimSpace(c.DecodeArgs[i])
|
||||
if strings.Contains(arg, replaceholder) {
|
||||
arg = strings.ReplaceAll(arg, replaceholder, base64Content)
|
||||
containHolder = true
|
||||
}
|
||||
return arg
|
||||
})
|
||||
if len(args) <= 0 || !containHolder {
|
||||
args = append(args, base64Content)
|
||||
}
|
||||
output, err := runCommand(c.DecodePath, args...)
|
||||
if err != nil || len(output) <= 0 || string(output) == "[RDM-ERROR]" {
|
||||
return str, false
|
||||
}
|
||||
|
||||
outputContent := make([]byte, base64.StdEncoding.DecodedLen(len(output)))
|
||||
n, err := base64.StdEncoding.Decode(outputContent, output)
|
||||
if err != nil {
|
||||
return str, false
|
||||
}
|
||||
return string(outputContent[:n]), true
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"github.com/vrischmann/userdir"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
func writeExecuteFile(content []byte, filename string) (string, error) {
|
||||
filepath := path.Join(userdir.GetConfigHome(), "TinyRDM", "decoder", filename)
|
||||
_ = os.Mkdir(path.Dir(filepath), 0777)
|
||||
err := os.WriteFile(filepath, content, 0777)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath, nil
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//go:build !windows
|
||||
|
||||
package convutil
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func runCommand(name string, arg ...string) ([]byte, error) {
|
||||
cmd := exec.Command(name, arg...)
|
||||
return cmd.Output()
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//go:build windows
|
||||
|
||||
package convutil
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func runCommand(name string, arg ...string) ([]byte, error) {
|
||||
cmd := exec.Command(name, arg...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Output()
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"tinyrdm/backend/types"
|
||||
strutil "tinyrdm/backend/utils/string"
|
||||
)
|
||||
|
||||
type DataConvert interface {
|
||||
Enable() bool
|
||||
Encode(string) (string, bool)
|
||||
Decode(string) (string, bool)
|
||||
}
|
||||
|
||||
var (
|
||||
jsonConv JsonConvert
|
||||
uniJsonConv UnicodeJsonConvert
|
||||
yamlConv YamlConvert
|
||||
xmlConv XmlConvert
|
||||
base64Conv Base64Convert
|
||||
binaryConv BinaryConvert
|
||||
hexConv HexConvert
|
||||
gzipConv GZipConvert
|
||||
deflateConv DeflateConvert
|
||||
zstdConv ZStdConvert
|
||||
lz4Conv LZ4Convert
|
||||
brotliConv BrotliConvert
|
||||
msgpackConv MsgpackConvert
|
||||
phpConv = NewPhpConvert()
|
||||
pickleConv = NewPickleConvert()
|
||||
)
|
||||
|
||||
var BuildInFormatters = map[string]DataConvert{
|
||||
types.FORMAT_JSON: jsonConv,
|
||||
types.FORMAT_UNICODE_JSON: uniJsonConv,
|
||||
types.FORMAT_YAML: yamlConv,
|
||||
types.FORMAT_XML: xmlConv,
|
||||
types.FORMAT_HEX: hexConv,
|
||||
types.FORMAT_BINARY: binaryConv,
|
||||
}
|
||||
|
||||
var BuildInDecoders = map[string]DataConvert{
|
||||
types.DECODE_BASE64: base64Conv,
|
||||
types.DECODE_GZIP: gzipConv,
|
||||
types.DECODE_DEFLATE: deflateConv,
|
||||
types.DECODE_ZSTD: zstdConv,
|
||||
types.DECODE_LZ4: lz4Conv,
|
||||
types.DECODE_BROTLI: brotliConv,
|
||||
types.DECODE_MSGPACK: msgpackConv,
|
||||
types.DECODE_PHP: phpConv,
|
||||
types.DECODE_PICKLE: pickleConv,
|
||||
}
|
||||
|
||||
// ConvertTo convert string to specified type
|
||||
// @param decodeType empty string indicates automatic detection
|
||||
// @param formatType empty string indicates automatic detection
|
||||
// @param custom decoder if any
|
||||
func ConvertTo(str, decodeType, formatType string, customDecoder []CmdConvert) (value, resultDecode, resultFormat string) {
|
||||
if len(str) <= 0 {
|
||||
// empty content
|
||||
if len(formatType) <= 0 {
|
||||
resultFormat = types.FORMAT_RAW
|
||||
} else {
|
||||
resultFormat = formatType
|
||||
}
|
||||
if len(decodeType) <= 0 {
|
||||
resultDecode = types.DECODE_NONE
|
||||
} else {
|
||||
resultDecode = decodeType
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decode first
|
||||
value, resultDecode = decodeWith(str, decodeType, customDecoder)
|
||||
// then format content
|
||||
if len(formatType) <= 0 {
|
||||
value, resultFormat = autoViewAs(value)
|
||||
} else {
|
||||
value, resultFormat = viewAs(value, formatType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func decodeWith(str, decodeType string, customDecoder []CmdConvert) (value, resultDecode string) {
|
||||
if len(decodeType) > 0 {
|
||||
value = str
|
||||
|
||||
if buildinDecoder, ok := BuildInDecoders[decodeType]; ok {
|
||||
if decodedStr, ok := buildinDecoder.Decode(str); ok {
|
||||
value = decodedStr
|
||||
}
|
||||
} else if decodeType != types.DECODE_NONE {
|
||||
for _, decoder := range customDecoder {
|
||||
if decoder.Name == decodeType {
|
||||
if decodedStr, ok := decoder.Decode(str); ok {
|
||||
value = decodedStr
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultDecode = decodeType
|
||||
return
|
||||
}
|
||||
|
||||
value, resultDecode = autoDecode(str, customDecoder)
|
||||
return
|
||||
}
|
||||
|
||||
// attempt try possible decode method
|
||||
// if no decode is possible, it will return the origin string value and "none" decode type
|
||||
func autoDecode(str string, customDecoder []CmdConvert) (value, resultDecode string) {
|
||||
if len(str) > 0 {
|
||||
// pure digit content may incorrect regard as some encoded type, skip decode
|
||||
if match, _ := regexp.MatchString(`^\d+$`, str); !match {
|
||||
var ok bool
|
||||
if len(str)%4 == 0 && len(str) >= 12 && !strutil.IsSameChar(str) {
|
||||
if value, ok = base64Conv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_BASE64
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if value, ok = gzipConv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_GZIP
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: skip decompress with deflate due to incorrect format checking
|
||||
//if value, ok = decodeDeflate(str); ok {
|
||||
// resultDecode = types.DECODE_DEFLATE
|
||||
// return
|
||||
//}
|
||||
|
||||
if value, ok = zstdConv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_ZSTD
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok = lz4Conv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_LZ4
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: skip decompress with brotli due to incorrect format checking
|
||||
//if value, ok = decodeBrotli(str); ok {
|
||||
// resultDecode = types.DECODE_BROTLI
|
||||
// return
|
||||
//}
|
||||
|
||||
if value, ok = msgpackConv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_MSGPACK
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok = phpConv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_PHP
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok = pickleConv.Decode(str); ok {
|
||||
resultDecode = types.DECODE_PICKLE
|
||||
return
|
||||
}
|
||||
|
||||
// try decode with custom decoder
|
||||
for _, decoder := range customDecoder {
|
||||
if decoder.Auto {
|
||||
if value, ok = decoder.Decode(str); ok {
|
||||
resultDecode = decoder.Name
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = str
|
||||
resultDecode = types.DECODE_NONE
|
||||
return
|
||||
}
|
||||
|
||||
func viewAs(str, formatType string) (value, resultFormat string) {
|
||||
if len(formatType) > 0 {
|
||||
value = str
|
||||
if buildinFormatter, ok := BuildInFormatters[formatType]; ok {
|
||||
if formattedStr, ok := buildinFormatter.Decode(str); ok {
|
||||
value = formattedStr
|
||||
}
|
||||
}
|
||||
resultFormat = formatType
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// attempt automatic convert to possible types
|
||||
// if no conversion is possible, it will return the origin string value and "plain text" type
|
||||
func autoViewAs(str string) (value, resultFormat string) {
|
||||
if len(str) > 0 {
|
||||
var ok bool
|
||||
if value, ok = jsonConv.Decode(str); ok {
|
||||
resultFormat = types.FORMAT_JSON
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok = yamlConv.Decode(str); ok {
|
||||
resultFormat = types.FORMAT_YAML
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok = xmlConv.Decode(str); ok {
|
||||
resultFormat = types.FORMAT_XML
|
||||
return
|
||||
}
|
||||
|
||||
if strutil.ContainsBinary(str) {
|
||||
if value, ok = hexConv.Decode(str); ok {
|
||||
resultFormat = types.FORMAT_HEX
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = str
|
||||
resultFormat = types.FORMAT_RAW
|
||||
return
|
||||
}
|
||||
|
||||
func SaveAs(str, format, decode string, customDecoder []CmdConvert) (value string, err error) {
|
||||
value = str
|
||||
if buildingFormatter, ok := BuildInFormatters[format]; ok {
|
||||
if formattedStr, ok := buildingFormatter.Encode(str); ok {
|
||||
value = formattedStr
|
||||
} else {
|
||||
err = errors.New("invalid " + format + " data")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if buildinDecoder, ok := BuildInDecoders[decode]; ok {
|
||||
if encodedValue, ok := buildinDecoder.Encode(str); ok {
|
||||
value = encodedValue
|
||||
} else {
|
||||
err = errors.New("fail to build " + decode)
|
||||
}
|
||||
return
|
||||
} else if decode != types.DECODE_NONE {
|
||||
for _, decoder := range customDecoder {
|
||||
if decoder.Name == decode {
|
||||
if encodedStr, ok := decoder.Encode(str); ok {
|
||||
value = encodedStr
|
||||
} else {
|
||||
err = errors.New("fail to build " + decode)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/klauspost/compress/flate"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DeflateConvert struct{}
|
||||
|
||||
func (d DeflateConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (d DeflateConvert) Encode(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer, err := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
if deflateStr, err := compress([]byte(str)); err == nil {
|
||||
return deflateStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (d DeflateConvert) Decode(str string) (string, bool) {
|
||||
reader := flate.NewReader(strings.NewReader(str))
|
||||
defer reader.Close()
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/klauspost/compress/gzip"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GZipConvert struct{}
|
||||
|
||||
func (GZipConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (GZipConvert) Encode(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := gzip.NewWriter(&buf)
|
||||
if _, err := writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
if gzipStr, err := compress([]byte(str)); err == nil {
|
||||
return gzipStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (GZipConvert) Decode(str string) (string, bool) {
|
||||
if reader, err := gzip.NewReader(strings.NewReader(str)); err == nil {
|
||||
defer reader.Close()
|
||||
var decompressed []byte
|
||||
if decompressed, err = io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HexConvert struct{}
|
||||
|
||||
func (HexConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (HexConvert) Encode(str string) (string, bool) {
|
||||
hexStrArr := strings.Split(str, "\\x")
|
||||
hexStr := strings.Join(hexStrArr, "")
|
||||
if decodeStr, err := hex.DecodeString(hexStr); err == nil {
|
||||
return string(decodeStr), true
|
||||
}
|
||||
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (HexConvert) Decode(str string) (string, bool) {
|
||||
decodeStr := hex.EncodeToString([]byte(str))
|
||||
decodeStr = strings.ToUpper(decodeStr)
|
||||
var resultStr strings.Builder
|
||||
for i := 0; i < len(decodeStr); i += 2 {
|
||||
resultStr.WriteString("\\x")
|
||||
resultStr.WriteString(decodeStr[i : i+2])
|
||||
}
|
||||
return resultStr.String(), true
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
strutil "tinyrdm/backend/utils/string"
|
||||
)
|
||||
|
||||
type JsonConvert struct{}
|
||||
|
||||
func (JsonConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (JsonConvert) Decode(str string) (string, bool) {
|
||||
trimedStr := strings.TrimSpace(str)
|
||||
if (strings.HasPrefix(trimedStr, "{") && strings.HasSuffix(trimedStr, "}")) ||
|
||||
(strings.HasPrefix(trimedStr, "[") && strings.HasSuffix(trimedStr, "]")) {
|
||||
return strutil.JSONBeautify(trimedStr, " "), true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (JsonConvert) Encode(str string) (string, bool) {
|
||||
return strutil.JSONMinify(str), true
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/pierrec/lz4/v4"
|
||||
"io"
|
||||
)
|
||||
|
||||
type LZ4Convert struct{}
|
||||
|
||||
func (LZ4Convert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (LZ4Convert) Encode(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := lz4.NewWriter(&buf)
|
||||
if _, err := writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
if gzipStr, err := compress([]byte(str)); err == nil {
|
||||
return gzipStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (LZ4Convert) Decode(str string) (string, bool) {
|
||||
reader := lz4.NewReader(bytes.NewReader([]byte(str)))
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
)
|
||||
|
||||
type MsgpackConvert struct{}
|
||||
|
||||
func (MsgpackConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c MsgpackConvert) Encode(str string) (string, bool) {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(str), &obj); err == nil {
|
||||
for k, v := range obj {
|
||||
obj[k] = c.TryFloatToInt(v)
|
||||
}
|
||||
if b, err := msgpack.Marshal(obj); err == nil {
|
||||
return string(b), true
|
||||
}
|
||||
}
|
||||
|
||||
if b, err := msgpack.Marshal(str); err != nil {
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (MsgpackConvert) Decode(str string) (string, bool) {
|
||||
var decodedStr string
|
||||
if err := msgpack.Unmarshal([]byte(str), &decodedStr); err == nil {
|
||||
return decodedStr, true
|
||||
}
|
||||
|
||||
var obj map[string]any
|
||||
if err := msgpack.Unmarshal([]byte(str), &obj); err == nil {
|
||||
if b, err := json.Marshal(obj); err == nil {
|
||||
if len(b) >= 10 {
|
||||
return string(b), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (c MsgpackConvert) TryFloatToInt(input any) any {
|
||||
switch val := input.(type) {
|
||||
case map[string]any:
|
||||
for k, v := range val {
|
||||
val[k] = c.TryFloatToInt(v)
|
||||
}
|
||||
return val
|
||||
case []any:
|
||||
for i, v := range val {
|
||||
val[i] = c.TryFloatToInt(v)
|
||||
}
|
||||
return val
|
||||
case float64:
|
||||
if val == float64(int(val)) {
|
||||
return int(val)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type PhpConvert struct {
|
||||
CmdConvert
|
||||
}
|
||||
|
||||
const phpDecodeCode = `
|
||||
<?php
|
||||
|
||||
$action = strtolower($argv[1]);
|
||||
$content = $argv[2];
|
||||
|
||||
if ($action === 'decode') {
|
||||
$decoded = base64_decode($content);
|
||||
if ($decoded !== false) {
|
||||
$obj = unserialize($decoded);
|
||||
if ($obj !== false) {
|
||||
$unserialized = json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($unserialized !== false) {
|
||||
echo base64_encode($unserialized);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'encode') {
|
||||
$decoded = base64_decode($content);
|
||||
if ($decoded !== false) {
|
||||
$json = json_decode($decoded, true);
|
||||
if ($json !== false) {
|
||||
$serialized = serialize($json);
|
||||
if ($serialized !== false) {
|
||||
echo base64_encode($serialized);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo '[RDM-ERROR]';
|
||||
`
|
||||
|
||||
func NewPhpConvert() *PhpConvert {
|
||||
c := CmdConvert{
|
||||
Name: "PHP",
|
||||
Auto: true,
|
||||
DecodePath: "php",
|
||||
EncodePath: "php",
|
||||
}
|
||||
|
||||
var err error
|
||||
if _, err = exec.LookPath(c.DecodePath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var filepath string
|
||||
if filepath, err = writeExecuteFile([]byte(phpDecodeCode), "php_decoder.php"); err != nil {
|
||||
return nil
|
||||
}
|
||||
c.DecodeArgs = []string{filepath, "decode"}
|
||||
c.EncodeArgs = []string{filepath, "encode"}
|
||||
|
||||
return &PhpConvert{
|
||||
CmdConvert: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PhpConvert) Enable() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *PhpConvert) Encode(str string) (string, bool) {
|
||||
if !p.Enable() {
|
||||
return str, false
|
||||
}
|
||||
return p.CmdConvert.Encode(str)
|
||||
}
|
||||
|
||||
func (p *PhpConvert) Decode(str string) (string, bool) {
|
||||
if !p.Enable() {
|
||||
return str, false
|
||||
}
|
||||
return p.CmdConvert.Decode(str)
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type PickleConvert struct {
|
||||
CmdConvert
|
||||
}
|
||||
|
||||
const pickleDecodeCode = `
|
||||
import base64
|
||||
import json
|
||||
import pickle
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 3:
|
||||
action = sys.argv[1].lower()
|
||||
content = sys.argv[2]
|
||||
|
||||
try:
|
||||
if action == 'decode':
|
||||
decoded = base64.b64decode(content)
|
||||
obj = pickle.loads(decoded)
|
||||
unserialized = json.dumps(obj, ensure_ascii=False)
|
||||
print(base64.b64encode(unserialized.encode('utf-8')).decode('utf-8'))
|
||||
elif action == 'encode':
|
||||
decoded = base64.b64decode(content)
|
||||
obj = json.loads(decoded)
|
||||
serialized = pickle.dumps(obj)
|
||||
print(base64.b64encode(serialized).decode('utf-8'))
|
||||
except:
|
||||
print('[RDM-ERROR]')
|
||||
else:
|
||||
print('[RDM-ERROR]')
|
||||
|
||||
`
|
||||
|
||||
func NewPickleConvert() *PickleConvert {
|
||||
c := CmdConvert{
|
||||
Name: "Pickle",
|
||||
Auto: true,
|
||||
}
|
||||
c.DecodePath, c.EncodePath = "python3", "python3"
|
||||
var err error
|
||||
if _, err = exec.LookPath(c.DecodePath); err != nil {
|
||||
c.DecodePath, c.EncodePath = "python", "python"
|
||||
if _, err = exec.LookPath(c.DecodePath); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// check if pickle available
|
||||
if runtime.GOOS == "darwin" {
|
||||
// the xcode-select installation prompt may appear on macOS
|
||||
// so check it manually in advance
|
||||
if _, err = exec.LookPath("xcode-select"); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = runCommand(c.DecodePath, "-c", "import pickle"); err != nil {
|
||||
return nil
|
||||
}
|
||||
var filepath string
|
||||
if filepath, err = writeExecuteFile([]byte(pickleDecodeCode), "pickle_decoder.py"); err != nil {
|
||||
return nil
|
||||
}
|
||||
c.DecodeArgs = []string{filepath, "decode"}
|
||||
c.EncodeArgs = []string{filepath, "encode"}
|
||||
|
||||
return &PickleConvert{
|
||||
CmdConvert: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PickleConvert) Enable() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *PickleConvert) Encode(str string) (string, bool) {
|
||||
if !p.Enable() {
|
||||
return str, false
|
||||
}
|
||||
return p.CmdConvert.Encode(str)
|
||||
}
|
||||
|
||||
func (p *PickleConvert) Decode(str string) (string, bool) {
|
||||
if !p.Enable() {
|
||||
return str, false
|
||||
}
|
||||
return p.CmdConvert.Decode(str)
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
strutil "tinyrdm/backend/utils/string"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type UnicodeJsonConvert struct{}
|
||||
|
||||
func (UnicodeJsonConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (UnicodeJsonConvert) Decode(str string) (string, bool) {
|
||||
trimedStr := strings.TrimSpace(str)
|
||||
if (strings.HasPrefix(trimedStr, "{") && strings.HasSuffix(trimedStr, "}")) ||
|
||||
(strings.HasPrefix(trimedStr, "[") && strings.HasSuffix(trimedStr, "]")) {
|
||||
resultStr := strutil.JSONBeautify(trimedStr, " ")
|
||||
if quoteStr, ok := UnquoteUnicodeJson([]byte(resultStr)); ok {
|
||||
return string(quoteStr), true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (UnicodeJsonConvert) Encode(str string) (string, bool) {
|
||||
return strutil.JSONMinify(str), true
|
||||
}
|
||||
|
||||
func UnquoteUnicodeJson(s []byte) ([]byte, bool) {
|
||||
var unquoted bytes.Buffer
|
||||
r := 0
|
||||
ls := len(s)
|
||||
for r < ls {
|
||||
c := s[r]
|
||||
offset := 1
|
||||
if c == '"' {
|
||||
// find next '"'
|
||||
for ; r+offset < ls; offset++ {
|
||||
if s[r+offset] == '"' && s[r+offset-1] != '\\' {
|
||||
offset += 1
|
||||
if ub, ok := unquoteBytes(s[r : r+offset]); ok {
|
||||
unquoted.WriteString(strconv.Quote(string(ub)))
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// can not find close '"' until reach to the end of content
|
||||
if r+offset >= ls {
|
||||
return nil, false
|
||||
}
|
||||
} else {
|
||||
unquoted.WriteByte(c)
|
||||
}
|
||||
r += offset
|
||||
}
|
||||
return unquoted.Bytes(), true
|
||||
}
|
||||
|
||||
func getu4(s []byte) rune {
|
||||
if len(s) < 6 || s[0] != '\\' || s[1] != 'u' {
|
||||
return -1
|
||||
}
|
||||
var r rune
|
||||
for _, c := range s[2:6] {
|
||||
switch {
|
||||
case '0' <= c && c <= '9':
|
||||
c = c - '0'
|
||||
case 'a' <= c && c <= 'f':
|
||||
c = c - 'a' + 10
|
||||
case 'A' <= c && c <= 'F':
|
||||
c = c - 'A' + 10
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
r = r*16 + rune(c)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func unquoteBytes(s []byte) (t []byte, ok bool) {
|
||||
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
|
||||
return
|
||||
}
|
||||
s = s[1 : len(s)-1]
|
||||
|
||||
// Check for unusual characters. If there are none,
|
||||
// then no unquoting is needed, so return a slice of the
|
||||
// original bytes.
|
||||
r := 0
|
||||
for r < len(s) {
|
||||
c := s[r]
|
||||
if c == '\\' || c == '"' || c < ' ' {
|
||||
break
|
||||
}
|
||||
if c < utf8.RuneSelf {
|
||||
r++
|
||||
continue
|
||||
}
|
||||
rr, size := utf8.DecodeRune(s[r:])
|
||||
if rr == utf8.RuneError && size == 1 {
|
||||
break
|
||||
}
|
||||
r += size
|
||||
}
|
||||
if r == len(s) {
|
||||
return s, true
|
||||
}
|
||||
|
||||
b := make([]byte, len(s)+2*utf8.UTFMax)
|
||||
w := copy(b, s[0:r])
|
||||
for r < len(s) {
|
||||
// Out of room? Can only happen if s is full of
|
||||
// malformed UTF-8 and we're replacing each
|
||||
// byte with RuneError.
|
||||
if w >= len(b)-2*utf8.UTFMax {
|
||||
nb := make([]byte, (len(b)+utf8.UTFMax)*2)
|
||||
copy(nb, b[0:w])
|
||||
b = nb
|
||||
}
|
||||
switch c := s[r]; {
|
||||
case c == '\\':
|
||||
r++
|
||||
if r >= len(s) {
|
||||
return
|
||||
}
|
||||
switch s[r] {
|
||||
default:
|
||||
return
|
||||
case '"', '\\', '/', '\'':
|
||||
b[w] = s[r]
|
||||
r++
|
||||
w++
|
||||
case 'b':
|
||||
b[w] = '\b'
|
||||
r++
|
||||
w++
|
||||
case 'f':
|
||||
b[w] = '\f'
|
||||
r++
|
||||
w++
|
||||
case 'n':
|
||||
b[w] = '\n'
|
||||
r++
|
||||
w++
|
||||
case 'r':
|
||||
b[w] = '\r'
|
||||
r++
|
||||
w++
|
||||
case 't':
|
||||
b[w] = '\t'
|
||||
r++
|
||||
w++
|
||||
case 'u':
|
||||
r--
|
||||
rr := getu4(s[r:])
|
||||
if rr < 0 {
|
||||
return
|
||||
}
|
||||
r += 6
|
||||
if utf16.IsSurrogate(rr) {
|
||||
rr1 := getu4(s[r:])
|
||||
if dec := utf16.DecodeRune(rr, rr1); dec != unicode.ReplacementChar {
|
||||
// A valid pair; consume.
|
||||
r += 6
|
||||
w += utf8.EncodeRune(b[w:], dec)
|
||||
break
|
||||
}
|
||||
// Invalid surrogate; fall back to replacement rune.
|
||||
rr = unicode.ReplacementChar
|
||||
}
|
||||
w += utf8.EncodeRune(b[w:], rr)
|
||||
}
|
||||
|
||||
// Quote, control characters are invalid.
|
||||
case c == '"', c < ' ':
|
||||
return
|
||||
|
||||
// ASCII
|
||||
case c < utf8.RuneSelf:
|
||||
b[w] = c
|
||||
r++
|
||||
w++
|
||||
|
||||
// Coerce to well-formed UTF-8.
|
||||
default:
|
||||
rr, size := utf8.DecodeRune(s[r:])
|
||||
r += size
|
||||
w += utf8.EncodeRune(b[w:], rr)
|
||||
}
|
||||
}
|
||||
return b[0:w], true
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type XmlConvert struct{}
|
||||
|
||||
func (XmlConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (XmlConvert) Encode(str string) (string, bool) {
|
||||
return str, true
|
||||
}
|
||||
|
||||
func (XmlConvert) Decode(str string) (string, bool) {
|
||||
trimedStr := strings.TrimSpace(str)
|
||||
if !strings.HasPrefix(trimedStr, "<") && !strings.HasSuffix(trimedStr, ">") {
|
||||
return str, false
|
||||
}
|
||||
var obj any
|
||||
err := xml.Unmarshal([]byte(trimedStr), &obj)
|
||||
return str, err == nil
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type YamlConvert struct{}
|
||||
|
||||
func (YamlConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (YamlConvert) Encode(str string) (string, bool) {
|
||||
return str, true
|
||||
}
|
||||
|
||||
func (YamlConvert) Decode(str string) (string, bool) {
|
||||
var obj map[string]any
|
||||
err := yaml.Unmarshal([]byte(str), &obj)
|
||||
return str, err == nil
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package convutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ZStdConvert struct{}
|
||||
|
||||
func (ZStdConvert) Enable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (ZStdConvert) Encode(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer, err := zstd.NewWriter(&buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
if zstdStr, err := compress([]byte(str)); err == nil {
|
||||
return zstdStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func (ZStdConvert) Decode(str string) (string, bool) {
|
||||
if reader, err := zstd.NewReader(strings.NewReader(str)); err == nil {
|
||||
defer reader.Close()
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -107,27 +107,6 @@ func Merge[M ~map[K]V, K Hashable, V any](mapArr ...M) M {
|
|||
return result
|
||||
}
|
||||
|
||||
// DeepMerge 深度递归覆盖src值到dst中
|
||||
// 将返回新的值
|
||||
func DeepMerge[M ~map[K]any, K Hashable](src1, src2 M) M {
|
||||
out := make(map[K]any, len(src1))
|
||||
for k, v := range src1 {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range src2 {
|
||||
if v1, ok := v.(map[K]any); ok {
|
||||
if bv, ok := out[k]; ok {
|
||||
if bv1, ok := bv.(map[K]any); ok {
|
||||
out[k] = DeepMerge(bv1, v1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Omit 根据条件省略指定元素
|
||||
func Omit[M ~map[K]V, K Hashable, V any](m M, omitFunc func(k K, v V) bool) (M, []K) {
|
||||
result := M{}
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type HttpProxy struct {
|
||||
scheme string // HTTP Proxy scheme
|
||||
host string // HTTP Proxy host or host:port
|
||||
auth *proxy.Auth // authentication
|
||||
forward proxy.Dialer // forwarding Dialer
|
||||
}
|
||||
|
||||
func (p *HttpProxy) Dial(network, addr string) (net.Conn, error) {
|
||||
c, err := p.forward.Dial(network, p.host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = c.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqUrl := &url.URL{
|
||||
Scheme: "",
|
||||
Host: addr,
|
||||
}
|
||||
|
||||
// create with CONNECT method
|
||||
req, err := http.NewRequest("CONNECT", reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
req.Close = false
|
||||
|
||||
// authentication
|
||||
if p.auth != nil {
|
||||
req.SetBasicAuth(p.auth.User, p.auth.Password)
|
||||
req.Header.Add("Proxy-Authorization", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
// send request
|
||||
err = req.Write(c)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(c), req)
|
||||
if err != nil {
|
||||
res.Body.Close()
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("proxy connection error: StatusCode[%d]", res.StatusCode)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func NewHttpProxyDialer(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) {
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
pwd, _ := u.User.Password()
|
||||
auth = &proxy.Auth{
|
||||
User: u.User.Username(),
|
||||
Password: pwd,
|
||||
}
|
||||
}
|
||||
|
||||
hp := &HttpProxy{
|
||||
scheme: u.Scheme,
|
||||
host: u.Host,
|
||||
auth: auth,
|
||||
forward: forward,
|
||||
}
|
||||
return hp, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxy.RegisterDialerType("http", NewHttpProxyDialer)
|
||||
proxy.RegisterDialerType("https", NewHttpProxyDialer)
|
||||
}
|
|
@ -1,137 +1,11 @@
|
|||
package sliceutil
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
. "tinyrdm/backend/utils"
|
||||
)
|
||||
|
||||
// Get 获取指定索引的值, 如果不存在则返回默认值
|
||||
func Get[S ~[]T, T any](arr S, index int, defaultVal T) T {
|
||||
if index < 0 || index >= len(arr) {
|
||||
return defaultVal
|
||||
}
|
||||
return arr[index]
|
||||
}
|
||||
|
||||
// Remove 删除指定索引的元素
|
||||
func Remove[S ~[]T, T any](arr S, index int) S {
|
||||
return append(arr[:index], arr[index+1:]...)
|
||||
}
|
||||
|
||||
// RemoveIf 移除指定条件的元素
|
||||
func RemoveIf[S ~[]T, T any](arr S, cond func(T) bool) S {
|
||||
l := len(arr)
|
||||
if l <= 0 {
|
||||
return arr
|
||||
}
|
||||
for i := l - 1; i >= 0; i-- {
|
||||
if cond(arr[i]) {
|
||||
arr = append(arr[:i], arr[i+1:]...)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// RemoveRange 删除从[from, to]部分元素
|
||||
func RemoveRange[S ~[]T, T any](arr S, from, to int) S {
|
||||
return append(arr[:from], arr[to:]...)
|
||||
}
|
||||
|
||||
// Find 查找指定条件的元素第一个出现位置
|
||||
func Find[S ~[]T, T any](arr S, matchFunc func(int) bool) (int, bool) {
|
||||
total := len(arr)
|
||||
for i := 0; i < total; i++ {
|
||||
if matchFunc(i) {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// AnyMatch 判断是否有任意元素符合条件
|
||||
func AnyMatch[S ~[]T, T any](arr S, matchFunc func(int) bool) bool {
|
||||
total := len(arr)
|
||||
if total > 0 {
|
||||
for i := 0; i < total; i++ {
|
||||
if matchFunc(i) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllMatch 判断是否所有元素都符合条件
|
||||
func AllMatch[S ~[]T, T any](arr S, matchFunc func(int) bool) bool {
|
||||
total := len(arr)
|
||||
for i := 0; i < total; i++ {
|
||||
if !matchFunc(i) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Equals 比较两个切片内容是否完全一致
|
||||
func Equals[S ~[]T, T comparable](arr1, arr2 S) bool {
|
||||
if &arr1 == &arr2 {
|
||||
return true
|
||||
}
|
||||
|
||||
len1, len2 := len(arr1), len(arr2)
|
||||
if len1 != len2 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len1; i++ {
|
||||
if arr1[i] != arr2[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Contains 判断数组是否包含指定元素
|
||||
func Contains[S ~[]T, T Hashable](arr S, elem T) bool {
|
||||
return AnyMatch(arr, func(idx int) bool {
|
||||
return arr[idx] == elem
|
||||
})
|
||||
}
|
||||
|
||||
// ContainsAny 判断数组是否包含任意指定元素
|
||||
func ContainsAny[S ~[]T, T Hashable](arr S, elems ...T) bool {
|
||||
for _, elem := range elems {
|
||||
if Contains(arr, elem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ContainsAll 判断数组是否包含所有指定元素
|
||||
func ContainsAll[S ~[]T, T Hashable](arr S, elems ...T) bool {
|
||||
for _, elem := range elems {
|
||||
if !Contains(arr, elem) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Filter 筛选出符合指定条件的所有元素
|
||||
func Filter[S ~[]T, T any](arr S, filterFunc func(int) bool) []T {
|
||||
total := len(arr)
|
||||
var result []T
|
||||
for i := 0; i < total; i++ {
|
||||
if filterFunc(i) {
|
||||
result = append(result, arr[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Map 数组映射转换
|
||||
// Map map items to new array
|
||||
func Map[S ~[]T, T any, R any](arr S, mappingFunc func(int) R) []R {
|
||||
total := len(arr)
|
||||
result := make([]R, total)
|
||||
|
@ -141,7 +15,7 @@ func Map[S ~[]T, T any, R any](arr S, mappingFunc func(int) R) []R {
|
|||
return result
|
||||
}
|
||||
|
||||
// FilterMap 数组过滤和映射转换
|
||||
// FilterMap filter and map items to new array
|
||||
func FilterMap[S ~[]T, T any, R any](arr S, mappingFunc func(int) (R, bool)) []R {
|
||||
total := len(arr)
|
||||
result := make([]R, 0, total)
|
||||
|
@ -155,68 +29,7 @@ func FilterMap[S ~[]T, T any, R any](arr S, mappingFunc func(int) (R, bool)) []R
|
|||
return result
|
||||
}
|
||||
|
||||
// ToMap 数组转键值对
|
||||
func ToMap[S ~[]T, T any, K Hashable, V any](arr S, mappingFunc func(int) (K, V)) map[K]V {
|
||||
total := len(arr)
|
||||
result := map[K]V{}
|
||||
for i := 0; i < total; i++ {
|
||||
key, val := mappingFunc(i)
|
||||
result[key] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Flat 二维数组扁平化
|
||||
func Flat[T any](arr [][]T) []T {
|
||||
total := len(arr)
|
||||
var result []T
|
||||
for i := 0; i < total; i++ {
|
||||
subTotal := len(arr[i])
|
||||
for j := 0; j < subTotal; j++ {
|
||||
result = append(result, arr[i][j])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FlatMap 二维数组扁平化映射
|
||||
func FlatMap[T any, R any](arr [][]T, mappingFunc func(int, int) R) []R {
|
||||
total := len(arr)
|
||||
var result []R
|
||||
for i := 0; i < total; i++ {
|
||||
subTotal := len(arr[i])
|
||||
for j := 0; j < subTotal; j++ {
|
||||
result = append(result, mappingFunc(i, j))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func FlatValueMap[T Hashable](arr [][]T) []T {
|
||||
return FlatMap(arr, func(i, j int) T {
|
||||
return arr[i][j]
|
||||
})
|
||||
}
|
||||
|
||||
// Reduce 数组累计
|
||||
func Reduce[S ~[]T, T any, R any](arr S, init R, reduceFunc func(R, T) R) R {
|
||||
result := init
|
||||
for _, item := range arr {
|
||||
result = reduceFunc(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Reverse 反转数组(会修改原数组)
|
||||
func Reverse[S ~[]T, T any](arr S) S {
|
||||
total := len(arr)
|
||||
for i := 0; i < total/2; i++ {
|
||||
arr[i], arr[total-i-1] = arr[total-i-1], arr[i]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// Join 数组拼接转字符串
|
||||
// Join join any array to a single string by custom function
|
||||
func Join[S ~[]T, T any](arr S, sep string, toStringFunc func(int) string) string {
|
||||
total := len(arr)
|
||||
if total <= 0 {
|
||||
|
@ -236,21 +49,14 @@ func Join[S ~[]T, T any](arr S, sep string, toStringFunc func(int) string) strin
|
|||
return sb.String()
|
||||
}
|
||||
|
||||
// JoinString 字符串数组拼接成字符串
|
||||
// JoinString join string array to a single string
|
||||
func JoinString(arr []string, sep string) string {
|
||||
return Join(arr, sep, func(idx int) string {
|
||||
return arr[idx]
|
||||
})
|
||||
}
|
||||
|
||||
// JoinInt 整形数组拼接转字符串
|
||||
func JoinInt(arr []int, sep string) string {
|
||||
return Join(arr, sep, func(idx int) string {
|
||||
return strconv.Itoa(arr[idx])
|
||||
})
|
||||
}
|
||||
|
||||
// Unique 数组去重
|
||||
// Unique filter unique item
|
||||
func Unique[S ~[]T, T Hashable](arr S) S {
|
||||
result := make(S, 0, len(arr))
|
||||
uniKeys := map[T]struct{}{}
|
||||
|
@ -263,136 +69,3 @@ func Unique[S ~[]T, T Hashable](arr S) S {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UniqueEx 数组去重(任意类型)
|
||||
// @param toKeyFunc 数组元素转为唯一标识字符串函数, 如转为哈希值等
|
||||
func UniqueEx[S ~[]T, T any](arr S, toKeyFunc func(i int) string) S {
|
||||
result := make(S, 0, len(arr))
|
||||
keyArr := Map(arr, toKeyFunc)
|
||||
uniKeys := map[string]struct{}{}
|
||||
var exists bool
|
||||
for i, item := range arr {
|
||||
if _, exists = uniKeys[keyArr[i]]; !exists {
|
||||
uniKeys[keyArr[i]] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Sort 顺序排序(会修改原数组)
|
||||
func Sort[S ~[]T, T Hashable](arr S) S {
|
||||
sort.Slice(arr, func(i, j int) bool {
|
||||
return arr[i] <= arr[j]
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
// SortDesc 倒序排序(会修改原数组)
|
||||
func SortDesc[S ~[]T, T Hashable](arr S) S {
|
||||
sort.Slice(arr, func(i, j int) bool {
|
||||
return arr[i] > arr[j]
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
// Union 返回两个切片共同拥有的元素
|
||||
func Union[S ~[]T, T Hashable](arr1 S, arr2 S) S {
|
||||
hashArr, compArr := arr1, arr2
|
||||
if len(arr1) < len(arr2) {
|
||||
hashArr, compArr = compArr, hashArr
|
||||
}
|
||||
hash := map[T]struct{}{}
|
||||
for _, item := range hashArr {
|
||||
hash[item] = struct{}{}
|
||||
}
|
||||
|
||||
uniq := map[T]struct{}{}
|
||||
ret := make(S, 0, len(compArr))
|
||||
exists := false
|
||||
for _, item := range compArr {
|
||||
if _, exists = hash[item]; exists {
|
||||
if _, exists = uniq[item]; !exists {
|
||||
ret = append(ret, item)
|
||||
uniq[item] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Exclude 返回不包含的元素
|
||||
func Exclude[S ~[]T, T Hashable](arr1 S, arr2 S) S {
|
||||
diff := make([]T, 0, len(arr1))
|
||||
hash := map[T]struct{}{}
|
||||
for _, item := range arr2 {
|
||||
hash[item] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range arr1 {
|
||||
if _, exists := hash[item]; !exists {
|
||||
diff = append(diff, item)
|
||||
}
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
// PadLeft 左边填充指定数量
|
||||
func PadLeft[S ~[]T, T any](arr S, val T, count int) S {
|
||||
prefix := make(S, count)
|
||||
for i := 0; i < count; i++ {
|
||||
prefix[i] = val
|
||||
}
|
||||
arr = append(prefix, arr...)
|
||||
return arr
|
||||
}
|
||||
|
||||
// PadRight 右边填充指定数量
|
||||
func PadRight[S ~[]T, T any](arr S, val T, count int) S {
|
||||
for i := 0; i < count; i++ {
|
||||
arr = append(arr, val)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// RemoveLeft 移除左侧相同元素
|
||||
func RemoveLeft[S ~[]T, T comparable](arr S, val T) S {
|
||||
for len(arr) > 0 && arr[0] == val {
|
||||
arr = arr[1:]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// RemoveRight 移除右侧相同元素
|
||||
func RemoveRight[S ~[]T, T comparable](arr S, val T) S {
|
||||
for {
|
||||
length := len(arr)
|
||||
if length > 0 && arr[length-1] == val {
|
||||
arr = arr[:length]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// Count 统计制定条件元素数量
|
||||
func Count[S ~[]T, T any](arr S, filter func(int) bool) int {
|
||||
count := 0
|
||||
for i := range arr {
|
||||
if filter(i) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Group 根据分组函数对数组进行分组汇总
|
||||
func Group[S ~[]T, T any, K Hashable, R any](arr S, groupFunc func(int) (K, R)) map[K][]R {
|
||||
ret := map[K][]R{}
|
||||
for i := range arr {
|
||||
key, val := groupFunc(i)
|
||||
ret[key] = append(ret[key], val)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package strutil
|
|||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
sliceutil "tinyrdm/backend/utils/slice"
|
||||
)
|
||||
|
||||
|
@ -129,3 +130,43 @@ func AnyToString(value interface{}, prefix string, layer int) (s string) {
|
|||
//
|
||||
// return output.String(), true
|
||||
//}
|
||||
|
||||
func SplitCmd(cmd string) []string {
|
||||
var result []string
|
||||
var curStr strings.Builder
|
||||
var preChar int32
|
||||
var quotesChar int32
|
||||
|
||||
cmdRune := []rune(cmd)
|
||||
for _, char := range cmdRune {
|
||||
if (char == '"' || char == '\'') && preChar != '\\' && (quotesChar == 0 || quotesChar == char) {
|
||||
if quotesChar != 0 {
|
||||
quotesChar = 0
|
||||
} else {
|
||||
quotesChar = char
|
||||
}
|
||||
} else if char == ' ' && quotesChar == 0 {
|
||||
result = append(result, curStr.String())
|
||||
curStr.Reset()
|
||||
} else {
|
||||
curStr.WriteRune(char)
|
||||
}
|
||||
preChar = char
|
||||
}
|
||||
result = append(result, curStr.String())
|
||||
|
||||
result = sliceutil.FilterMap(result, func(i int) (string, bool) {
|
||||
var part = strings.TrimSpace(result[i])
|
||||
if len(part) <= 0 {
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(part, "\\") {
|
||||
if unquotePart, e := strconv.Unquote(`"` + part + `"`); e == nil {
|
||||
return unquotePart, true
|
||||
}
|
||||
}
|
||||
return part, true
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"unicode"
|
||||
)
|
||||
|
||||
func containsBinary(str string) bool {
|
||||
func ContainsBinary(str string) bool {
|
||||
//buf := []byte(str)
|
||||
//size := 0
|
||||
//for start := 0; start < len(buf); start += size {
|
||||
|
@ -15,6 +15,9 @@ func containsBinary(str string) bool {
|
|||
//}
|
||||
rs := []rune(str)
|
||||
for _, r := range rs {
|
||||
if r == unicode.ReplacementChar {
|
||||
return true
|
||||
}
|
||||
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
|
||||
return true
|
||||
}
|
||||
|
@ -22,7 +25,7 @@ func containsBinary(str string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func isSameChar(str string) bool {
|
||||
func IsSameChar(str string) bool {
|
||||
if len(str) <= 0 {
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -1,463 +0,0 @@
|
|||
package strutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/klauspost/compress/flate"
|
||||
"github.com/klauspost/compress/gzip"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"tinyrdm/backend/types"
|
||||
)
|
||||
|
||||
// ConvertTo convert string to specified type
|
||||
// @param decodeType empty string indicates automatic detection
|
||||
// @param formatType empty string indicates automatic detection
|
||||
func ConvertTo(str, decodeType, formatType string) (value, resultDecode, resultFormat string) {
|
||||
if len(str) <= 0 {
|
||||
// empty content
|
||||
if len(formatType) <= 0 {
|
||||
resultFormat = types.FORMAT_RAW
|
||||
} else {
|
||||
resultFormat = formatType
|
||||
}
|
||||
if len(decodeType) <= 0 {
|
||||
resultDecode = types.DECODE_NONE
|
||||
} else {
|
||||
resultDecode = decodeType
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decode first
|
||||
value, resultDecode = decodeWith(str, decodeType)
|
||||
// then format content
|
||||
value, resultFormat = viewAs(value, formatType)
|
||||
return
|
||||
}
|
||||
|
||||
func decodeWith(str, decodeType string) (value, resultDecode string) {
|
||||
if len(decodeType) > 0 {
|
||||
switch decodeType {
|
||||
case types.DECODE_NONE:
|
||||
value = str
|
||||
resultDecode = decodeType
|
||||
return
|
||||
|
||||
case types.DECODE_BASE64:
|
||||
if base64Str, ok := decodeBase64(str); ok {
|
||||
value = base64Str
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultDecode = decodeType
|
||||
return
|
||||
|
||||
case types.DECODE_GZIP:
|
||||
if gzipStr, ok := decodeGZip(str); ok {
|
||||
value = gzipStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultDecode = decodeType
|
||||
return
|
||||
|
||||
case types.DECODE_DEFLATE:
|
||||
if falteStr, ok := decodeDeflate(str); ok {
|
||||
value = falteStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultDecode = decodeType
|
||||
return
|
||||
|
||||
case types.DECODE_ZSTD:
|
||||
if zstdStr, ok := decodeZStd(str); ok {
|
||||
value = zstdStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultDecode = decodeType
|
||||
return
|
||||
|
||||
case types.DECODE_BROTLI:
|
||||
if brotliStr, ok := decodeBrotli(str); ok {
|
||||
value = brotliStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultDecode = decodeType
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return autoDecode(str)
|
||||
}
|
||||
|
||||
// attempt try possible decode method
|
||||
// if no decode is possible, it will return the origin string value and "none" decode type
|
||||
func autoDecode(str string) (value, resultDecode string) {
|
||||
if len(str) > 0 {
|
||||
// pure digit content may incorrect regard as some encoded type, skip decode
|
||||
if match, _ := regexp.MatchString(`^\d+$`, str); !match {
|
||||
var ok bool
|
||||
if len(str)%4 == 0 && len(str) >= 12 && !isSameChar(str) {
|
||||
if value, ok = decodeBase64(str); ok {
|
||||
resultDecode = types.DECODE_BASE64
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if value, ok = decodeGZip(str); ok {
|
||||
resultDecode = types.DECODE_GZIP
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: skip decompress with deflate due to incorrect format checking
|
||||
//if value, ok = decodeDeflate(str); ok {
|
||||
// resultDecode = types.DECODE_DEFLATE
|
||||
// return
|
||||
//}
|
||||
|
||||
if value, ok = decodeZStd(str); ok {
|
||||
resultDecode = types.DECODE_ZSTD
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok = decodeBrotli(str); ok {
|
||||
resultDecode = types.DECODE_BROTLI
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = str
|
||||
resultDecode = types.DECODE_NONE
|
||||
return
|
||||
}
|
||||
|
||||
func viewAs(str, formatType string) (value, resultFormat string) {
|
||||
if len(formatType) > 0 {
|
||||
switch formatType {
|
||||
case types.FORMAT_RAW:
|
||||
value = str
|
||||
resultFormat = formatType
|
||||
return
|
||||
|
||||
case types.FORMAT_JSON:
|
||||
if jsonStr, ok := decodeJson(str); ok {
|
||||
value = jsonStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultFormat = formatType
|
||||
return
|
||||
|
||||
case types.FORMAT_HEX:
|
||||
if hexStr, ok := decodeToHex(str); ok {
|
||||
value = hexStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultFormat = formatType
|
||||
return
|
||||
|
||||
case types.FORMAT_BINARY:
|
||||
if binStr, ok := decodeBinary(str); ok {
|
||||
value = binStr
|
||||
} else {
|
||||
value = str
|
||||
}
|
||||
resultFormat = formatType
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return autoViewAs(str)
|
||||
}
|
||||
|
||||
// attempt automatic convert to possible types
|
||||
// if no conversion is possible, it will return the origin string value and "plain text" type
|
||||
func autoViewAs(str string) (value, resultFormat string) {
|
||||
if len(str) > 0 {
|
||||
var ok bool
|
||||
if value, ok = decodeJson(str); ok {
|
||||
resultFormat = types.FORMAT_JSON
|
||||
return
|
||||
}
|
||||
|
||||
if containsBinary(str) {
|
||||
if value, ok = decodeToHex(str); ok {
|
||||
resultFormat = types.FORMAT_HEX
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = str
|
||||
resultFormat = types.FORMAT_RAW
|
||||
return
|
||||
}
|
||||
|
||||
func decodeJson(str string) (string, bool) {
|
||||
trimedStr := strings.TrimSpace(str)
|
||||
if (strings.HasPrefix(trimedStr, "{") && strings.HasSuffix(trimedStr, "}")) ||
|
||||
(strings.HasPrefix(trimedStr, "[") && strings.HasSuffix(trimedStr, "]")) {
|
||||
var out bytes.Buffer
|
||||
if err := json.Indent(&out, []byte(trimedStr), "", " "); err == nil {
|
||||
return out.String(), true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func decodeBase64(str string) (string, bool) {
|
||||
if decodedStr, err := base64.StdEncoding.DecodeString(str); err == nil {
|
||||
if s := string(decodedStr); !containsBinary(s) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func decodeBinary(str string) (string, bool) {
|
||||
var binary strings.Builder
|
||||
for _, char := range str {
|
||||
binary.WriteString(fmt.Sprintf("%08b", int(char)))
|
||||
}
|
||||
return binary.String(), true
|
||||
}
|
||||
|
||||
func decodeToHex(str string) (string, bool) {
|
||||
decodeStr := hex.EncodeToString([]byte(str))
|
||||
var resultStr strings.Builder
|
||||
for i := 0; i < len(decodeStr); i += 2 {
|
||||
resultStr.WriteString("\\x")
|
||||
resultStr.WriteString(decodeStr[i : i+2])
|
||||
}
|
||||
return resultStr.String(), true
|
||||
}
|
||||
|
||||
func decodeGZip(str string) (string, bool) {
|
||||
if reader, err := gzip.NewReader(strings.NewReader(str)); err == nil {
|
||||
defer reader.Close()
|
||||
var decompressed []byte
|
||||
if decompressed, err = io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func decodeDeflate(str string) (string, bool) {
|
||||
reader := flate.NewReader(strings.NewReader(str))
|
||||
defer reader.Close()
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func decodeZStd(str string) (string, bool) {
|
||||
if reader, err := zstd.NewReader(strings.NewReader(str)); err == nil {
|
||||
defer reader.Close()
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func decodeBrotli(str string) (string, bool) {
|
||||
reader := brotli.NewReader(strings.NewReader(str))
|
||||
if decompressed, err := io.ReadAll(reader); err == nil {
|
||||
return string(decompressed), true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func SaveAs(str, format, decode string) (value string, err error) {
|
||||
value = str
|
||||
switch format {
|
||||
case types.FORMAT_JSON:
|
||||
if jsonStr, ok := encodeJson(str); ok {
|
||||
value = jsonStr
|
||||
} else {
|
||||
err = errors.New("invalid json data")
|
||||
return
|
||||
}
|
||||
|
||||
case types.FORMAT_HEX:
|
||||
if hexStr, ok := encodeHex(str); ok {
|
||||
value = hexStr
|
||||
} else {
|
||||
err = errors.New("invalid hex data")
|
||||
return
|
||||
}
|
||||
|
||||
case types.FORMAT_BINARY:
|
||||
if binStr, ok := encodeBinary(str); ok {
|
||||
value = binStr
|
||||
} else {
|
||||
err = errors.New("invalid binary data")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch decode {
|
||||
case types.DECODE_NONE:
|
||||
return
|
||||
|
||||
case types.DECODE_BASE64:
|
||||
value, _ = encodeBase64(value)
|
||||
return
|
||||
|
||||
case types.DECODE_GZIP:
|
||||
if gzipStr, ok := encodeGZip(str); ok {
|
||||
value = gzipStr
|
||||
} else {
|
||||
err = errors.New("fail to build gzip")
|
||||
}
|
||||
return
|
||||
|
||||
case types.DECODE_DEFLATE:
|
||||
if deflateStr, ok := encodeDeflate(str); ok {
|
||||
value = deflateStr
|
||||
} else {
|
||||
err = errors.New("fail to build deflate")
|
||||
}
|
||||
return
|
||||
|
||||
case types.DECODE_ZSTD:
|
||||
if zstdStr, ok := encodeZStd(str); ok {
|
||||
value = zstdStr
|
||||
} else {
|
||||
err = errors.New("fail to build zstd")
|
||||
}
|
||||
return
|
||||
|
||||
case types.DECODE_BROTLI:
|
||||
if brotliStr, ok := encodeBrotli(str); ok {
|
||||
value = brotliStr
|
||||
} else {
|
||||
err = errors.New("fail to build brotli")
|
||||
}
|
||||
return
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
func encodeJson(str string) (string, bool) {
|
||||
var dst bytes.Buffer
|
||||
if err := json.Compact(&dst, []byte(str)); err != nil {
|
||||
return str, false
|
||||
}
|
||||
return dst.String(), true
|
||||
}
|
||||
|
||||
func encodeBase64(str string) (string, bool) {
|
||||
return base64.StdEncoding.EncodeToString([]byte(str)), true
|
||||
}
|
||||
|
||||
func encodeBinary(str string) (string, bool) {
|
||||
var result strings.Builder
|
||||
total := len(str)
|
||||
for i := 0; i < total; i += 8 {
|
||||
b, _ := strconv.ParseInt(str[i:i+8], 2, 8)
|
||||
result.WriteByte(byte(b))
|
||||
}
|
||||
return result.String(), true
|
||||
}
|
||||
|
||||
func encodeHex(str string) (string, bool) {
|
||||
hexStrArr := strings.Split(str, "\\x")
|
||||
hexStr := strings.Join(hexStrArr, "")
|
||||
if decodeStr, err := hex.DecodeString(hexStr); err == nil {
|
||||
return string(decodeStr), true
|
||||
}
|
||||
|
||||
return str, false
|
||||
}
|
||||
|
||||
func encodeGZip(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := gzip.NewWriter(&buf)
|
||||
if _, err := writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
if gzipStr, err := compress([]byte(str)); err == nil {
|
||||
return gzipStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func encodeDeflate(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer, err := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
if deflateStr, err := compress([]byte(str)); err == nil {
|
||||
return deflateStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func encodeZStd(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer, err := zstd.NewWriter(&buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
if zstdStr, err := compress([]byte(str)); err == nil {
|
||||
return zstdStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
||||
|
||||
func encodeBrotli(str string) (string, bool) {
|
||||
var compress = func(b []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := brotli.NewWriter(&buf)
|
||||
if _, err := writer.Write([]byte(str)); err != nil {
|
||||
writer.Close()
|
||||
return "", err
|
||||
}
|
||||
writer.Close()
|
||||
return string(buf.Bytes()), nil
|
||||
}
|
||||
if brotliStr, err := compress([]byte(str)); err == nil {
|
||||
return brotliStr, true
|
||||
}
|
||||
return str, false
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
package strutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Convert from https://github.com/ObuchiYuki/SwiftJSONFormatter
|
||||
|
||||
// ArrayIterator defines the iterator for an array
|
||||
type ArrayIterator[T any] struct {
|
||||
array []T
|
||||
head int
|
||||
}
|
||||
|
||||
// NewArrayIterator initializes a new ArrayIterator with the given array
|
||||
func NewArrayIterator[T any](array []T) *ArrayIterator[T] {
|
||||
return &ArrayIterator[T]{
|
||||
array: array,
|
||||
head: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// HasNext returns true if there are more elements to iterate over
|
||||
func (it *ArrayIterator[T]) HasNext() bool {
|
||||
return it.head+1 < len(it.array)
|
||||
}
|
||||
|
||||
// PeekNext returns the next element without advancing the iterator
|
||||
func (it *ArrayIterator[T]) PeekNext() *T {
|
||||
if it.head+1 < len(it.array) {
|
||||
return &it.array[it.head+1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Next returns the next element and advances the iterator
|
||||
func (it *ArrayIterator[T]) Next() *T {
|
||||
defer func() {
|
||||
it.head++
|
||||
}()
|
||||
return it.PeekNext()
|
||||
}
|
||||
|
||||
// JSONBeautify formats a JSON string with indentation
|
||||
func JSONBeautify(value string, indent string) string {
|
||||
if len(indent) <= 0 {
|
||||
indent = " "
|
||||
}
|
||||
return format(value, indent, "\n", " ")
|
||||
}
|
||||
|
||||
// JSONMinify formats a JSON string by removing all unnecessary whitespace
|
||||
func JSONMinify(value string) string {
|
||||
return format(value, "", "", "")
|
||||
}
|
||||
|
||||
// format applies the specified formatting to a JSON string
|
||||
func format(value string, indent string, newLine string, separator string) string {
|
||||
var formatted strings.Builder
|
||||
chars := NewArrayIterator([]rune(value))
|
||||
indentLevel := 0
|
||||
|
||||
for chars.HasNext() {
|
||||
if char := chars.Next(); char != nil {
|
||||
switch *char {
|
||||
case '{', '[':
|
||||
formatted.WriteRune(*char)
|
||||
consumeWhitespaces(chars)
|
||||
peeked := chars.PeekNext()
|
||||
if peeked != nil && (*peeked == '}' || *peeked == ']') {
|
||||
chars.Next()
|
||||
formatted.WriteRune(*peeked)
|
||||
} else {
|
||||
indentLevel++
|
||||
formatted.WriteString(newLine)
|
||||
formatted.WriteString(strings.Repeat(indent, indentLevel))
|
||||
}
|
||||
case '}', ']':
|
||||
indentLevel--
|
||||
formatted.WriteString(newLine)
|
||||
formatted.WriteString(strings.Repeat(indent, max(0, indentLevel)))
|
||||
formatted.WriteRune(*char)
|
||||
case '"':
|
||||
str := consumeString(chars)
|
||||
//str = convertUnicodeString(str)
|
||||
formatted.WriteString(str)
|
||||
case ',':
|
||||
consumeWhitespaces(chars)
|
||||
formatted.WriteRune(',')
|
||||
peeked := chars.PeekNext()
|
||||
if peeked != nil && *peeked != '}' && *peeked != ']' {
|
||||
formatted.WriteString(newLine)
|
||||
formatted.WriteString(strings.Repeat(indent, max(0, indentLevel)))
|
||||
}
|
||||
case ':':
|
||||
formatted.WriteString(":" + separator)
|
||||
default:
|
||||
if !unicode.IsSpace(*char) {
|
||||
formatted.WriteRune(*char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatted.String()
|
||||
}
|
||||
|
||||
// consumeWhitespaces advances the iterator past any whitespace characters
|
||||
func consumeWhitespaces(iter *ArrayIterator[rune]) {
|
||||
for iter.HasNext() {
|
||||
if peeked := iter.PeekNext(); peeked != nil && unicode.IsSpace(*peeked) {
|
||||
iter.Next()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// consumeString consumes a JSON string value from the iterator
|
||||
func consumeString(iter *ArrayIterator[rune]) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteRune('"')
|
||||
escaping := false
|
||||
|
||||
for iter.HasNext() {
|
||||
if char := iter.Next(); char != nil {
|
||||
if *char == '\n' {
|
||||
return sb.String() // Unterminated string
|
||||
}
|
||||
|
||||
sb.WriteRune(*char)
|
||||
|
||||
if escaping {
|
||||
escaping = false
|
||||
} else {
|
||||
if *char == '\\' {
|
||||
escaping = true
|
||||
}
|
||||
if *char == '"' {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func convertUnicodeString(str string) string {
|
||||
// TODO: quote UTF-16 characters
|
||||
//if len(str) > 2 {
|
||||
// if unqStr, err := strconv.Unquote(str); err == nil {
|
||||
// return strconv.Quote(unqStr)
|
||||
// }
|
||||
//}
|
||||
return str
|
||||
}
|
|
@ -8,7 +8,7 @@ import (
|
|||
// EncodeRedisKey encode the redis key to integer array
|
||||
// if key contains binary which could not display on ui, convert the key to char array
|
||||
func EncodeRedisKey(key string) any {
|
||||
if containsBinary(key) {
|
||||
if ContainsBinary(key) {
|
||||
b := []byte(key)
|
||||
arr := make([]int, len(b))
|
||||
for i, bb := range b {
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<string>11.7.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<string>11.7.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
clear
|
||||
BLACK="\033[0;30m"
|
||||
DARK_GRAY="\033[1;30m"
|
||||
BLUE="\033[0;34m"
|
||||
LIGHT_BLUE="\033[1;34m"
|
||||
GREEN="\033[0;32m"
|
||||
LIGHT_GREEN="\033[1;32m"
|
||||
CYAN="\033[0;36m"
|
||||
LIGHT_CYAN="\033[1;36m"
|
||||
RED="\033[0;31m"
|
||||
LIGHT_RED="\033[1;31m"
|
||||
PURPLE="\033[0;35m"
|
||||
LIGHT_PURPLE="\033[1;35m"
|
||||
BROWN="\033[0;33m"
|
||||
YELLOW="\033[0;33m"
|
||||
LIGHT_GRAY="\033[0;37m"
|
||||
WHITE="\033[1;37m"
|
||||
NC="\033[0m"
|
||||
|
||||
parentPath=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
|
||||
cd "$parentPath"
|
||||
appPath=$( find "$parentPath" -name '*.app' -maxdepth 1)
|
||||
appName=${appPath##*/}
|
||||
appBashName=${appName// /\ }
|
||||
appDIR="/Applications/${appBashName}"
|
||||
echo -e "This tool fix these situations: \"${appBashName}\" is damaged and can't not be opened."
|
||||
echo ""
|
||||
if [ ! -d "$appDIR" ];then
|
||||
echo ""
|
||||
echo -e "Execution result: ${RED}You haven't installed ${appBashName} yet, please install it first.${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Please enter your login password, and then press enter. (The password is invisible during input)${NC}"
|
||||
sudo spctl --master-disable
|
||||
sudo xattr -rd com.apple.quarantine /Applications/"$appBashName"
|
||||
sudo xattr -rc /Applications/"$appBashName"
|
||||
sudo codesign --sign - --force --deep /Applications/"$appBashName"
|
||||
echo -e "Execution result: ${GREEN}Already fixed! ${NC} ${appBashName} will work correctly.${NC}"
|
||||
fi
|
||||
echo -e "You can close this window now"
|
|
@ -0,0 +1,42 @@
|
|||
#!/bin/bash
|
||||
clear
|
||||
BLACK="\033[0;30m"
|
||||
DARK_GRAY="\033[1;30m"
|
||||
BLUE="\033[0;34m"
|
||||
LIGHT_BLUE="\033[1;34m"
|
||||
GREEN="\033[0;32m"
|
||||
LIGHT_GREEN="\033[1;32m"
|
||||
CYAN="\033[0;36m"
|
||||
LIGHT_CYAN="\033[1;36m"
|
||||
RED="\033[0;31m"
|
||||
LIGHT_RED="\033[1;31m"
|
||||
PURPLE="\033[0;35m"
|
||||
LIGHT_PURPLE="\033[1;35m"
|
||||
BROWN="\033[0;33m"
|
||||
YELLOW="\033[0;33m"
|
||||
LIGHT_GRAY="\033[0;37m"
|
||||
WHITE="\033[1;37m"
|
||||
NC="\033[0m"
|
||||
|
||||
parentPath=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
|
||||
cd "$parentPath"
|
||||
appPath=$( find "$parentPath" -name '*.app' -maxdepth 1)
|
||||
appName=${appPath##*/}
|
||||
appBashName=${appName// /\ }
|
||||
appDIR="/Applications/${appBashName}"
|
||||
echo -e "『${appBashName} 提示已损坏,无法打开/ 来自身份不明的开发者』等问题修复工具"
|
||||
echo ""
|
||||
# 未安装APP时提醒安装,已安装绕过公证
|
||||
if [ ! -d "$appDIR" ];then
|
||||
echo ""
|
||||
echo -e "执行结果:${RED}您还未安装 ${appBashName} ,请先安装${NC}"
|
||||
else
|
||||
# 绕过公证
|
||||
echo -e "${YELLOW}请输入开机密码,输入完成后按下回车键(输入过程中密码是看不见的)${NC}"
|
||||
sudo spctl --master-disable
|
||||
sudo xattr -rd com.apple.quarantine /Applications/"$appBashName"
|
||||
sudo xattr -rc /Applications/"$appBashName"
|
||||
sudo codesign --sign - --force --deep /Applications/"$appBashName"
|
||||
echo -e "执行结果:${GREEN}修复成功!${NC}您现在可以正常运行 ${appBashName} 了。${NC}"
|
||||
fi
|
||||
echo -e "本窗口可以关闭啦!"
|
|
@ -3,6 +3,7 @@ Version: {{.Info.ProductVersion}}
|
|||
Section: base
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends: libwebkit2gtk-4.0-37
|
||||
Maintainer: {{.Author.Name}} <{{.Author.Email}}>
|
||||
Homepage: https://redis.tinycraft.cc/
|
||||
Description: {{.Info.Comments}}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
|
@ -1,14 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset='UTF-8' />
|
||||
<meta content='width=device-width, initial-scale=1.0' name='viewport' />
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>Tiny RDM</title>
|
||||
<!-- <link href="./src/styles/style.scss" rel="stylesheet">-->
|
||||
</head>
|
||||
<body>
|
||||
<div id='app'></div>
|
||||
<script src='./src/main.js' type='module'></script>
|
||||
<body spellcheck="false">
|
||||
<div id="app"></div>
|
||||
<script src="./src/main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -9,24 +9,26 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"highlight.js": "^11.9.0",
|
||||
"chart.js": "^4.4.8",
|
||||
"copy-text-to-clipboard": "^3.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^2.1.7",
|
||||
"sass": "^1.69.5",
|
||||
"vue": "^3.3.8",
|
||||
"vue-i18n": "^9.7.1",
|
||||
"monaco-editor": "^0.47.0",
|
||||
"pinia": "^3.0.1",
|
||||
"sass": "^1.85.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-i18n": "^11.1.2",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"naive-ui": "^2.35.0",
|
||||
"prettier": "^3.1.0",
|
||||
"unplugin-auto-import": "^0.16.7",
|
||||
"unplugin-icons": "^0.17.4",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vite": "^5.0.2"
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"naive-ui": "^2.41.0",
|
||||
"prettier": "^3.5.3",
|
||||
"unplugin-auto-import": "^19.1.1",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"unplugin-vue-components": "^28.4.1",
|
||||
"vite": "^6.2.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
13b88be029994e83241098ae7c3ee1c4
|
||||
47ebcfd89e9e219e5b4ccf43ca2aa197
|
|
@ -4,26 +4,25 @@ import NewKeyDialog from './components/dialogs/NewKeyDialog.vue'
|
|||
import PreferencesDialog from './components/dialogs/PreferencesDialog.vue'
|
||||
import RenameKeyDialog from './components/dialogs/RenameKeyDialog.vue'
|
||||
import SetTtlDialog from './components/dialogs/SetTtlDialog.vue'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import plaintext from 'highlight.js/lib/languages/plaintext'
|
||||
import AddFieldsDialog from './components/dialogs/AddFieldsDialog.vue'
|
||||
import AppContent from './AppContent.vue'
|
||||
import GroupDialog from './components/dialogs/GroupDialog.vue'
|
||||
import DeleteKeyDialog from './components/dialogs/DeleteKeyDialog.vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { h, onMounted, ref, watch } from 'vue'
|
||||
import usePreferencesStore from './stores/preferences.js'
|
||||
import useConnectionStore from './stores/connections.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { darkTheme, NButton, NSpace } from 'naive-ui'
|
||||
import KeyFilterDialog from './components/dialogs/KeyFilterDialog.vue'
|
||||
import { WindowSetDarkTheme, WindowSetLightTheme } from 'wailsjs/runtime/runtime.js'
|
||||
import { Environment, WindowSetDarkTheme, WindowSetLightTheme } from 'wailsjs/runtime/runtime.js'
|
||||
import { darkThemeOverrides, themeOverrides } from '@/utils/theme.js'
|
||||
import AboutDialog from '@/components/dialogs/AboutDialog.vue'
|
||||
import FlushDbDialog from '@/components/dialogs/FlushDbDialog.vue'
|
||||
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('plaintext', plaintext)
|
||||
import ExportKeyDialog from '@/components/dialogs/ExportKeyDialog.vue'
|
||||
import ImportKeyDialog from '@/components/dialogs/ImportKeyDialog.vue'
|
||||
import { Info } from 'wailsjs/go/services/systemService.js'
|
||||
import DecoderDialog from '@/components/dialogs/DecoderDialog.vue'
|
||||
import { loadModule, trackEvent } from '@/utils/analytics.js'
|
||||
|
||||
const prefStore = usePreferencesStore()
|
||||
const connectionStore = useConnectionStore()
|
||||
|
@ -33,10 +32,66 @@ onMounted(async () => {
|
|||
try {
|
||||
initializing.value = true
|
||||
await prefStore.loadFontList()
|
||||
await prefStore.loadBuildInDecoder()
|
||||
await connectionStore.initConnections()
|
||||
if (prefStore.autoCheckUpdate) {
|
||||
prefStore.checkForUpdate()
|
||||
}
|
||||
const env = await Environment()
|
||||
loadModule(env.buildType !== 'dev' && prefStore.general.allowTrack !== false).then(() => {
|
||||
Info().then(({ data }) => {
|
||||
trackEvent('startup', data, true)
|
||||
})
|
||||
})
|
||||
|
||||
// show greetings and user behavior tracking statements
|
||||
if (!!!prefStore.behavior.welcomed) {
|
||||
const n = $notification.show({
|
||||
title: () => i18n.t('dialogue.welcome.title'),
|
||||
content: () => i18n.t('dialogue.welcome.content'),
|
||||
// duration: 5000,
|
||||
keepAliveOnHover: true,
|
||||
closable: false,
|
||||
meta: ' ',
|
||||
action: () =>
|
||||
h(
|
||||
NSpace,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
secondary: true,
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
prefStore.setAsWelcomed(false)
|
||||
n.destroy()
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => i18n.t('dialogue.welcome.reject'),
|
||||
},
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
secondary: true,
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
prefStore.setAsWelcomed(true)
|
||||
n.destroy()
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => i18n.t('dialogue.welcome.accept'),
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
|
@ -57,7 +112,6 @@ watch(
|
|||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:hljs="hljs"
|
||||
:inline-theme-disabled="true"
|
||||
:locale="prefStore.themeLocale"
|
||||
:theme="prefStore.isDark ? darkTheme : undefined"
|
||||
|
@ -74,9 +128,12 @@ watch(
|
|||
<add-fields-dialog />
|
||||
<rename-key-dialog />
|
||||
<delete-key-dialog />
|
||||
<export-key-dialog />
|
||||
<import-key-dialog />
|
||||
<flush-db-dialog />
|
||||
<set-ttl-dialog />
|
||||
<preferences-dialog />
|
||||
<decoder-dialog />
|
||||
<about-dialog />
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup>
|
||||
import ContentPane from './components/content/ContentPane.vue'
|
||||
import BrowserPane from './components/sidebar/BrowserPane.vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { debounce, get } from 'lodash'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { debounce } from 'lodash'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import Ribbon from './components/sidebar/Ribbon.vue'
|
||||
import ConnectionPane from './components/sidebar/ConnectionPane.vue'
|
||||
|
@ -13,7 +13,7 @@ import ContentLogPane from './components/content/ContentLogPane.vue'
|
|||
import ContentValueTab from '@/components/content/ContentValueTab.vue'
|
||||
import ToolbarControlWidget from '@/components/common/ToolbarControlWidget.vue'
|
||||
import { EventsOn, WindowIsFullscreen, WindowIsMaximised, WindowToggleMaximise } from 'wailsjs/runtime/runtime.js'
|
||||
import { isMacOS } from '@/utils/platform.js'
|
||||
import { isMacOS, isWindows } from '@/utils/platform.js'
|
||||
import iconUrl from '@/assets/images/icon.png'
|
||||
import ResizeableWrapper from '@/components/common/ResizeableWrapper.vue'
|
||||
import { extraTheme } from '@/utils/extra_theme.js'
|
||||
|
@ -43,14 +43,11 @@ const handleResize = () => {
|
|||
saveSidebarWidth()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tabStore.nav,
|
||||
(nav) => {
|
||||
if (nav === 'log') {
|
||||
logPaneRef.value?.refresh()
|
||||
}
|
||||
},
|
||||
)
|
||||
watchEffect(() => {
|
||||
if (tabStore.nav === 'log') {
|
||||
logPaneRef.value?.refresh()
|
||||
}
|
||||
})
|
||||
|
||||
const logoWrapperWidth = computed(() => {
|
||||
return `${data.navMenuWidth + prefStore.behavior.asideWidth - 4}px`
|
||||
|
@ -60,6 +57,9 @@ const logoPaddingLeft = ref(10)
|
|||
const maximised = ref(false)
|
||||
const hideRadius = ref(false)
|
||||
const wrapperStyle = computed(() => {
|
||||
if (isWindows()) {
|
||||
return {}
|
||||
}
|
||||
return hideRadius.value
|
||||
? {}
|
||||
: {
|
||||
|
@ -68,6 +68,11 @@ const wrapperStyle = computed(() => {
|
|||
}
|
||||
})
|
||||
const spinStyle = computed(() => {
|
||||
if (isWindows()) {
|
||||
return {
|
||||
backgroundColor: themeVars.value.bodyColor,
|
||||
}
|
||||
}
|
||||
return hideRadius.value
|
||||
? {
|
||||
backgroundColor: themeVars.value.bodyColor,
|
||||
|
@ -112,7 +117,28 @@ onMounted(async () => {
|
|||
onToggleFullscreen(fullscreen === true)
|
||||
const maximised = await WindowIsMaximised()
|
||||
onToggleMaximize(maximised)
|
||||
window.addEventListener('keydown', onKeyShortcut)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyShortcut)
|
||||
})
|
||||
|
||||
const onKeyShortcut = (e) => {
|
||||
const isCtrlOn = isMacOS() ? e.metaKey : e.ctrlKey
|
||||
switch (e.key) {
|
||||
case 'w':
|
||||
if (isCtrlOn) {
|
||||
// close current tab
|
||||
const tabStore = useTabStore()
|
||||
const currentTab = tabStore.currentTab
|
||||
if (currentTab != null) {
|
||||
tabStore.closeTab(currentTab.name)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -136,10 +162,10 @@ onMounted(async () => {
|
|||
}">
|
||||
<n-space :size="3" :wrap="false" :wrap-item="false" align="center">
|
||||
<n-avatar :size="32" :src="iconUrl" color="#0000" style="min-width: 32px" />
|
||||
<div style="min-width: 68px; font-weight: 800">Tiny RDM</div>
|
||||
<div style="min-width: 68px; white-space: nowrap; font-weight: 800">Tiny RDM</div>
|
||||
<transition name="fade">
|
||||
<n-text v-if="tabStore.nav === 'browser'" class="ellipsis" strong style="font-size: 13px">
|
||||
- {{ get(tabStore.currentTab, 'name') }}
|
||||
- {{ tabStore.currentTabName }}
|
||||
</n-text>
|
||||
</transition>
|
||||
</n-space>
|
||||
|
@ -148,7 +174,7 @@ onMounted(async () => {
|
|||
<div v-show="tabStore.nav === 'browser'" class="app-toolbar-tab flex-item-expand">
|
||||
<content-value-tab />
|
||||
</div>
|
||||
<div class="flex-item-expand"></div>
|
||||
<div class="flex-item-expand" style="min-width: 15px"></div>
|
||||
<!-- simulate window control buttons -->
|
||||
<toolbar-control-widget
|
||||
v-if="!isMacOS()"
|
||||
|
@ -174,13 +200,15 @@ onMounted(async () => {
|
|||
@update:size="handleResize">
|
||||
<browser-pane
|
||||
v-for="t in tabStore.tabs"
|
||||
v-show="get(tabStore.currentTab, 'name') === t.name"
|
||||
v-show="tabStore.currentTabName === t.name"
|
||||
:key="t.name"
|
||||
:db="t.db"
|
||||
:server="t.name"
|
||||
class="app-side flex-item-expand" />
|
||||
</resizeable-wrapper>
|
||||
<content-pane
|
||||
v-for="t in tabStore.tabs"
|
||||
v-show="get(tabStore.currentTab, 'name') === t.name"
|
||||
v-show="tabStore.currentTabName === t.name"
|
||||
:key="t.name"
|
||||
:server="t.name"
|
||||
class="flex-item-expand" />
|
||||
|
@ -234,6 +262,7 @@ onMounted(async () => {
|
|||
align-self: flex-end;
|
||||
margin-bottom: -1px;
|
||||
margin-left: 3px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
|
@ -0,0 +1,69 @@
|
|||
<script setup>
|
||||
import { isNumber } from 'lodash'
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
on: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultValue: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
onRefresh: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'update:on', 'update:interval'])
|
||||
|
||||
const onToggle = (on) => {
|
||||
emit('update:on', on === true)
|
||||
if (on) {
|
||||
let interval = props.interval
|
||||
if (!isNumber(interval)) {
|
||||
interval = props.defaultValue
|
||||
}
|
||||
interval = Math.max(1, interval)
|
||||
emit('update:interval', interval)
|
||||
emit('toggle', true)
|
||||
} else {
|
||||
emit('toggle', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-form :show-feedback="false" label-align="right" label-placement="left" label-width="auto" size="small">
|
||||
<n-form-item :label="$t('interface.auto_refresh')">
|
||||
<n-switch :loading="props.loading" :value="props.on" @update:value="onToggle" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('interface.refresh_interval')">
|
||||
<n-input-number
|
||||
:autofocus="false"
|
||||
:default-value="props.defaultValue"
|
||||
:disabled="props.on"
|
||||
:max="9999"
|
||||
:min="1"
|
||||
:show-button="false"
|
||||
:value="props.interval"
|
||||
style="max-width: 100px"
|
||||
@update:value="(val) => emit('update:interval', val)">
|
||||
<template #suffix>
|
||||
{{ $t('common.unit_second') }}
|
||||
</template>
|
||||
</n-input-number>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
|
@ -1,7 +1,9 @@
|
|||
<script setup>
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { get, map } from 'lodash'
|
||||
import { get, isEmpty, some } from 'lodash'
|
||||
import { NIcon, NText } from 'naive-ui'
|
||||
import { useRender } from '@/utils/render.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
|
@ -9,8 +11,12 @@ const props = defineProps({
|
|||
value: '',
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
value: {},
|
||||
type: Array,
|
||||
value: () => [],
|
||||
},
|
||||
menuOption: {
|
||||
type: Array,
|
||||
value: () => [],
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
|
@ -20,16 +26,14 @@ const props = defineProps({
|
|||
disabled: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value'])
|
||||
const emit = defineEmits(['update:value', 'menu'])
|
||||
const i18n = useI18n()
|
||||
const render = useRender()
|
||||
|
||||
const renderHeader = () => {
|
||||
return h('div', { class: 'type-selector-header' }, [h(NText, null, () => props.tooltip)])
|
||||
}
|
||||
|
||||
const renderLabel = (option) => {
|
||||
return h('div', { class: 'type-selector-item' }, option.label)
|
||||
}
|
||||
|
||||
const dropdownOption = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
|
@ -42,19 +46,53 @@ const dropdownOption = computed(() => {
|
|||
type: 'divider',
|
||||
},
|
||||
]
|
||||
return [
|
||||
...options,
|
||||
...map(props.options, (t) => {
|
||||
return {
|
||||
key: t,
|
||||
label: t,
|
||||
if (get(props.options, 0) instanceof Array) {
|
||||
// multiple group
|
||||
for (let i = 0; i < props.options.length; i++) {
|
||||
if (i !== 0 && !isEmpty(props.options[i])) {
|
||||
// add divider
|
||||
options.push({
|
||||
key: 'header-divider' + (i + 1),
|
||||
type: 'divider',
|
||||
})
|
||||
}
|
||||
}),
|
||||
]
|
||||
for (const option of props.options[i]) {
|
||||
options.push({
|
||||
key: option,
|
||||
label: option,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const option of props.options) {
|
||||
options.push({
|
||||
key: option,
|
||||
label: option,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEmpty(props.menuOption)) {
|
||||
options.push({
|
||||
key: 'header-divider',
|
||||
type: 'divider',
|
||||
})
|
||||
for (const { key, label } of props.menuOption) {
|
||||
options.push({
|
||||
key,
|
||||
label: i18n.t(label),
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const onDropdownSelect = (key) => {
|
||||
emit('update:value', key)
|
||||
if (some(props.menuOption, { key })) {
|
||||
emit('menu', key)
|
||||
} else {
|
||||
emit('update:value', key)
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = computed(() => {
|
||||
|
@ -71,9 +109,8 @@ const onDropdownShow = (show) => {
|
|||
<n-dropdown
|
||||
:disabled="props.disabled"
|
||||
:options="dropdownOption"
|
||||
:render-label="renderLabel"
|
||||
:render-label="({ label }) => render.renderLabel(label, { class: 'type-selector-item' })"
|
||||
:show-arrow="true"
|
||||
:title="props.tooltip"
|
||||
:value="props.value"
|
||||
trigger="click"
|
||||
@select="onDropdownSelect"
|
||||
|
|
|
@ -4,30 +4,34 @@ import Delete from '@/components/icons/Delete.vue'
|
|||
import Edit from '@/components/icons/Edit.vue'
|
||||
import Close from '@/components/icons/Close.vue'
|
||||
import Save from '@/components/icons/Save.vue'
|
||||
import Copy from '@/components/icons/Copy.vue'
|
||||
import Refresh from '@/components/icons/Refresh.vue'
|
||||
|
||||
const props = defineProps({
|
||||
bindKey: String,
|
||||
editing: Boolean,
|
||||
readonly: Boolean,
|
||||
canRefresh: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'save', 'cancel'])
|
||||
const emit = defineEmits(['edit', 'delete', 'copy', 'refresh', 'save', 'cancel'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO: support multiple save -->
|
||||
<div v-if="props.editing" class="flex-box-h edit-column-func">
|
||||
<icon-button :icon="Save" @click="emit('save')" />
|
||||
<icon-button :icon="Close" @click="emit('cancel')" />
|
||||
</div>
|
||||
<div v-else class="flex-box-h edit-column-func">
|
||||
<icon-button v-if="!props.readonly" :icon="Edit" @click="emit('edit')" />
|
||||
<icon-button :icon="Copy" :title="$t('interface.copy_value')" @click="emit('copy')" />
|
||||
<icon-button v-if="props.canRefresh" :icon="Refresh" :title="$t('interface.reload')" @click="emit('refresh')" />
|
||||
<icon-button v-if="!props.readonly" :icon="Edit" :title="$t('interface.edit_row')" @click="emit('edit')" />
|
||||
<n-popconfirm
|
||||
:negative-text="$t('common.cancel')"
|
||||
:positive-text="$t('common.confirm')"
|
||||
@positive-click="emit('delete')">
|
||||
<template #trigger>
|
||||
<icon-button :icon="Delete" />
|
||||
<icon-button :icon="Delete" :title="$t('interface.delete_row')" />
|
||||
</template>
|
||||
{{ $t('dialogue.remove_tip', { name: props.bindKey }) }}
|
||||
</n-popconfirm>
|
||||
|
|
|
@ -17,7 +17,6 @@ const handleUpdateValue = (val) => {
|
|||
<template>
|
||||
<div style="min-height: 22px">
|
||||
<template v-if="props.isEdit">
|
||||
<!-- TODO: ADD FULL SCREEN EDIT SUPPORT -->
|
||||
<n-input :value="props.value" @update:value="handleUpdateValue" />
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
|
@ -1,29 +1,45 @@
|
|||
<script setup>
|
||||
import { SelectFile } from 'wailsjs/go/services/systemService.js'
|
||||
import { get } from 'lodash'
|
||||
import { get, isEmpty } from 'lodash'
|
||||
|
||||
const props = defineProps({
|
||||
value: String,
|
||||
placeholder: String,
|
||||
disabled: Boolean,
|
||||
ext: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value'])
|
||||
|
||||
const onInput = (val) => {
|
||||
emit('update:value', val)
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
emit('update:value', '')
|
||||
}
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const { success, data } = await SelectFile()
|
||||
const { success, data } = await SelectFile('', isEmpty(props.ext) ? null : [props.ext])
|
||||
if (success) {
|
||||
const path = get(data, 'path', '')
|
||||
emit('update:value', path)
|
||||
} else {
|
||||
emit('update:value', '')
|
||||
// emit('update:value', '')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="props.value" :disabled="props.disabled" :placeholder="placeholder" clearable />
|
||||
<n-input
|
||||
:disabled="props.disabled"
|
||||
:placeholder="placeholder"
|
||||
:title="props.value"
|
||||
:value="props.value"
|
||||
clearable
|
||||
@clear="onClear"
|
||||
@input="onInput" />
|
||||
<n-button :disabled="props.disabled" :focusable="false" @click="handleSelectFile">...</n-button>
|
||||
</n-input-group>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
<script setup>
|
||||
import { SaveFile } from 'wailsjs/go/services/systemService.js'
|
||||
import { get } from 'lodash'
|
||||
|
||||
const props = defineProps({
|
||||
value: String,
|
||||
placeholder: String,
|
||||
disabled: Boolean,
|
||||
defaultPath: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value'])
|
||||
|
||||
const onInput = (val) => {
|
||||
emit('update:value', val)
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
emit('update:value', '')
|
||||
}
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
const { success, data } = await SaveFile(null, props.defaultPath, ['csv'])
|
||||
if (success) {
|
||||
const path = get(data, 'path', '')
|
||||
emit('update:value', path)
|
||||
} else {
|
||||
emit('update:value', '')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-input-group>
|
||||
<n-input
|
||||
:disabled="props.disabled"
|
||||
:placeholder="placeholder"
|
||||
:value="props.value"
|
||||
clearable
|
||||
@clear="onClear"
|
||||
@input="onInput" />
|
||||
<n-button :disabled="props.disabled" :focusable="false" @click="handleSaveFile">...</n-button>
|
||||
</n-input-group>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
|
@ -1,12 +1,14 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const props = defineProps({
|
||||
tooltip: String,
|
||||
tTooltip: String,
|
||||
tooltipDelay: {
|
||||
type: Number,
|
||||
default: 800,
|
||||
},
|
||||
type: String,
|
||||
icon: [String, Object],
|
||||
size: {
|
||||
|
@ -25,24 +27,36 @@ const props = defineProps({
|
|||
border: Boolean,
|
||||
disabled: Boolean,
|
||||
buttonStyle: [String, Object],
|
||||
buttonClass: [String, Object],
|
||||
small: Boolean,
|
||||
secondary: Boolean,
|
||||
tertiary: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasTooltip = computed(() => {
|
||||
return props.tooltip || props.tTooltip
|
||||
return props.tooltip || props.tTooltip || slots.tooltip
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip v-if="hasTooltip" :show-arrow="false">
|
||||
<n-tooltip v-if="hasTooltip" :delay="tooltipDelay" :keep-alive-on-hover="false" :show-arrow="false">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
:class="props.buttonClass"
|
||||
:color="props.color"
|
||||
:disabled="disabled"
|
||||
:disabled="props.disabled"
|
||||
:focusable="false"
|
||||
:loading="loading"
|
||||
:secondary="props.secondary"
|
||||
:size="props.small ? 'small' : ''"
|
||||
:style="props.buttonStyle"
|
||||
:text="!border"
|
||||
:type="type"
|
||||
:tertiary="props.tertiary"
|
||||
:text="!props.border"
|
||||
:type="props.type"
|
||||
@click.prevent="emit('click')">
|
||||
<template #icon>
|
||||
<slot>
|
||||
|
@ -53,16 +67,23 @@ const hasTooltip = computed(() => {
|
|||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ props.tTooltip ? $t(props.tTooltip) : props.tooltip }}
|
||||
<slot name="tooltip">
|
||||
{{ props.tTooltip ? $t(props.tTooltip) : props.tooltip }}
|
||||
</slot>
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
v-else
|
||||
:class="props.buttonClass"
|
||||
:color="props.color"
|
||||
:disabled="disabled"
|
||||
:disabled="props.disabled"
|
||||
:focusable="false"
|
||||
:loading="loading"
|
||||
:text="!border"
|
||||
:type="type"
|
||||
:secondary="props.secondary"
|
||||
:size="props.small ? 'small' : ''"
|
||||
:style="props.buttonStyle"
|
||||
:tertiary="props.tertiary"
|
||||
:text="!props.border"
|
||||
:type="props.type"
|
||||
@click.prevent="emit('click')">
|
||||
<template #icon>
|
||||
<slot>
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { NSpace, useThemeVars } from 'naive-ui'
|
||||
import { types, typesBgColor, typesColor, typesShortName } from '@/consts/support_redis_type.js'
|
||||
import { get, isEmpty, map, toUpper } from 'lodash'
|
||||
import RedisTypeTag from '@/components/common/RedisTypeTag.vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: 'ALL',
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-start',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableTip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value', 'select'])
|
||||
|
||||
const options = computed(() => {
|
||||
const opts = map(types, (v) => ({
|
||||
label: v,
|
||||
key: v,
|
||||
}))
|
||||
return [{ label: 'ALL', key: 'ALL' }, ...opts]
|
||||
})
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
const renderIcon = (option) => {
|
||||
return h(RedisTypeTag, {
|
||||
type: option.key,
|
||||
defaultLabel: 'A',
|
||||
short: true,
|
||||
size: 'small',
|
||||
inverse: option.key === props.value,
|
||||
})
|
||||
}
|
||||
|
||||
const renderLabel = (option) => {
|
||||
const children = [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontWeight: option.key === props.value ? 'bold' : 'normal',
|
||||
},
|
||||
},
|
||||
option.label,
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ style: { width: '16px' } },
|
||||
h(RedisTypeTag, {
|
||||
type: toUpper(option.key),
|
||||
point: true,
|
||||
style: { display: option.key === props.value ? 'block' : 'none' },
|
||||
}),
|
||||
),
|
||||
]
|
||||
return h(NSpace, { align: 'center', wrapItem: false }, () => children)
|
||||
}
|
||||
|
||||
const fontColor = computed(() => {
|
||||
return get(typesColor, props.value, '')
|
||||
})
|
||||
|
||||
const backgroundColor = computed(() => {
|
||||
return get(typesBgColor, props.value, '')
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return get(typesShortName, toUpper(props.value), 'A')
|
||||
})
|
||||
|
||||
const handleSelect = (select) => {
|
||||
if (props.value !== select) {
|
||||
emit('update:value', select)
|
||||
emit('select', select)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="props.disabled">
|
||||
<n-tooltip :disabled="isEmpty(props.disableTip)">
|
||||
<div>{{ props.disableTip }}</div>
|
||||
<template #trigger>
|
||||
<n-tag
|
||||
:bordered="true"
|
||||
:color="{ color: backgroundColor, textColor: fontColor }"
|
||||
class="redis-tag"
|
||||
disabled
|
||||
size="medium"
|
||||
strong>
|
||||
{{ displayValue }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-dropdown
|
||||
:disabled="props.disabled"
|
||||
:options="options"
|
||||
:placement="props.placement"
|
||||
:render-icon="renderIcon"
|
||||
:render-label="renderLabel"
|
||||
show-arrow
|
||||
@select="handleSelect">
|
||||
<n-tag
|
||||
:bordered="true"
|
||||
:color="{ color: backgroundColor, textColor: fontColor }"
|
||||
:disabled="props.disabled"
|
||||
class="redis-tag"
|
||||
size="medium"
|
||||
strong>
|
||||
{{ displayValue }}
|
||||
</n-tag>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.redis-tag {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
:deep(.dropdown-type-item) {
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
|
@ -1,51 +1,125 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { typesBgColor, typesColor, validType } from '@/consts/support_redis_type.js'
|
||||
import { typesBgColor, typesColor, typesShortName } from '@/consts/support_redis_type.js'
|
||||
import Binary from '@/components/icons/Binary.vue'
|
||||
import { get, toUpper } from 'lodash'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import Loading from '@/components/icons/Loading.vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
validator(value) {
|
||||
return validType(value)
|
||||
},
|
||||
default: 'STRING',
|
||||
},
|
||||
defaultLabel: String,
|
||||
binaryKey: Boolean,
|
||||
bordered: Boolean,
|
||||
size: String,
|
||||
short: Boolean,
|
||||
point: Boolean,
|
||||
pointSize: {
|
||||
type: Number,
|
||||
default: 14,
|
||||
},
|
||||
round: Boolean,
|
||||
inverse: Boolean,
|
||||
loading: Boolean,
|
||||
})
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const fontColor = computed(() => {
|
||||
return typesColor[props.type]
|
||||
if (props.inverse) {
|
||||
return props.loading ? themeVars.value.tagColor : typesBgColor[props.type]
|
||||
} else {
|
||||
return props.loading ? themeVars.value.textColorBase : typesColor[props.type]
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundColor = computed(() => {
|
||||
return typesBgColor[props.type]
|
||||
if (props.inverse) {
|
||||
return props.loading ? themeVars.value.textColorBase : typesColor[props.type]
|
||||
} else {
|
||||
return props.loading ? themeVars.value.tagColor : typesBgColor[props.type]
|
||||
}
|
||||
})
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.short) {
|
||||
return get(typesShortName, toUpper(props.type), props.defaultLabel || 'N')
|
||||
}
|
||||
return toUpper(props.type)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="props.point"
|
||||
:class="{ 'redis-type-tag-loading': props.loading }"
|
||||
:style="{
|
||||
backgroundColor: fontColor,
|
||||
width: Math.max(props.pointSize, 5) + 'px',
|
||||
height: Math.max(props.pointSize, 5) + 'px',
|
||||
}"
|
||||
class="redis-type-tag-round redis-type-tag-point" />
|
||||
<n-tag
|
||||
:bordered="props.bordered"
|
||||
:class="[props.size === 'small' ? 'redis-type-tag-small' : 'redis-type-tag']"
|
||||
:color="{ color: backgroundColor, borderColor: fontColor, textColor: fontColor }"
|
||||
v-else
|
||||
:class="{
|
||||
'redis-type-tag-normal': !props.short && props.size !== 'small',
|
||||
'redis-type-tag-small': !props.short && props.size === 'small',
|
||||
'redis-type-tag-round': props.round,
|
||||
'redis-type-tag-loading': props.loading,
|
||||
'redis-type-tag': props.short,
|
||||
}"
|
||||
:color="{ color: backgroundColor, textColor: fontColor }"
|
||||
:size="props.size"
|
||||
bordered
|
||||
strong>
|
||||
{{ props.type }}
|
||||
<b v-if="!props.loading">{{ label }}</b>
|
||||
<n-icon v-else-if="props.short" size="14">
|
||||
<loading stroke-width="4" />
|
||||
</n-icon>
|
||||
<b v-else>LOADING</b>
|
||||
<template #icon>
|
||||
<n-icon v-if="binaryKey" :component="Binary" size="18" />
|
||||
</template>
|
||||
</n-tag>
|
||||
<!-- <div class="redis-type-tag flex-box-h" :style="{backgroundColor: backgroundColor}">{{ props.type }}</div>-->
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.redis-type-tag {
|
||||
.redis-type-tag-round {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.redis-type-tag-normal {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.redis-type-tag-small {
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.redis-type-tag-loading {
|
||||
animation: fadeInOut 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.redis-type-tag {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -101,6 +101,7 @@ const handleMouseOver = () => {
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
transition: background-color 0.3s ease-in;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.resize-divider-hide {
|
||||
|
|
|
@ -12,6 +12,10 @@ const props = defineProps({
|
|||
},
|
||||
icons: Array,
|
||||
tTooltips: Array,
|
||||
tTooltipPlacement: {
|
||||
type: String,
|
||||
default: 'bottom',
|
||||
},
|
||||
iconSize: {
|
||||
type: [Number, String],
|
||||
default: 20,
|
||||
|
@ -45,6 +49,7 @@ const handleSwitch = (idx) => {
|
|||
v-for="(icon, i) in props.icons"
|
||||
:key="i"
|
||||
:disabled="!(props.tTooltips && props.tTooltips[i])"
|
||||
:placement="props.tTooltipPlacement"
|
||||
:show-arrow="false">
|
||||
<template #trigger>
|
||||
<n-button :focusable="false" :size="props.size" :tertiary="i !== props.value" @click="handleSwitch(i)">
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
unit: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value', 'update:unit'])
|
||||
|
||||
const unit = [
|
||||
{
|
||||
value: 1,
|
||||
label: 'common.second',
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
label: 'common.minute',
|
||||
},
|
||||
{
|
||||
value: 3600,
|
||||
label: 'common.hour',
|
||||
},
|
||||
{
|
||||
value: 86400,
|
||||
label: 'common.day',
|
||||
},
|
||||
]
|
||||
|
||||
const unitValue = computed(() => {
|
||||
switch (props.unit) {
|
||||
case 60:
|
||||
return 60
|
||||
case 3600:
|
||||
return 3600
|
||||
case 86400:
|
||||
return 86400
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-input-group>
|
||||
<n-input-number
|
||||
:max="Number.MAX_SAFE_INTEGER"
|
||||
:min="-1"
|
||||
:show-button="false"
|
||||
:value="props.value"
|
||||
class="flex-item-expand"
|
||||
@update:value="(val) => emit('update:value', val)" />
|
||||
<n-select
|
||||
:options="unit"
|
||||
:render-label="({ label }) => $t(label)"
|
||||
:value="unitValue"
|
||||
style="max-width: 150px"
|
||||
@update:value="(val) => emit('update:unit', val)" />
|
||||
</n-input-group>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
|
@ -26,7 +26,7 @@ const filterServerOption = computed(() => {
|
|||
value: server,
|
||||
}))
|
||||
options.splice(0, 0, {
|
||||
label: i18n.t('common.all'),
|
||||
label: 'common.all',
|
||||
value: '',
|
||||
})
|
||||
return options
|
||||
|
@ -36,7 +36,7 @@ const tableRef = ref(null)
|
|||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: i18n.t('log.exec_time'),
|
||||
title: () => i18n.t('log.exec_time'),
|
||||
key: 'timestamp',
|
||||
defaultSortOrder: 'ascend',
|
||||
sorter: 'default',
|
||||
|
@ -48,7 +48,7 @@ const columns = computed(() => [
|
|||
},
|
||||
},
|
||||
{
|
||||
title: i18n.t('log.server'),
|
||||
title: () => i18n.t('log.server'),
|
||||
key: 'server',
|
||||
filterOptionValue: data.server,
|
||||
filter: (value, row) => {
|
||||
|
@ -62,7 +62,7 @@ const columns = computed(() => [
|
|||
},
|
||||
},
|
||||
{
|
||||
title: i18n.t('log.cmd'),
|
||||
title: () => i18n.t('log.cmd'),
|
||||
key: 'cmd',
|
||||
titleAlign: 'center',
|
||||
filterOptionValue: data.keyword,
|
||||
|
@ -76,14 +76,14 @@ const columns = computed(() => [
|
|||
return h(
|
||||
'div',
|
||||
null,
|
||||
map(cmdList, (c) => h('div', null, c)),
|
||||
map(cmdList, (c) => h('div', { class: 'cmd-line' }, c)),
|
||||
)
|
||||
}
|
||||
return cmd
|
||||
return h('div', { class: 'cmd-line' }, cmd)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.t('log.cost_time'),
|
||||
title: () => i18n.t('log.cost_time'),
|
||||
key: 'cost',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
|
@ -107,7 +107,8 @@ const loadHistory = async () => {
|
|||
data.history = list || []
|
||||
} finally {
|
||||
data.loading = false
|
||||
tableRef.value?.scrollTo({ top: 999999 })
|
||||
await nextTick()
|
||||
tableRef.value?.scrollTo({ position: 'bottom' })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,8 +119,9 @@ const cleanHistory = async () => {
|
|||
const success = await browserStore.cleanCmdHistory()
|
||||
if (success) {
|
||||
data.history = []
|
||||
tableRef.value?.scrollTo({ top: 0 })
|
||||
$message.success(i18n.t('common.success'))
|
||||
await nextTick()
|
||||
tableRef.value?.scrollTo({ position: 'top' })
|
||||
$message.success(i18n.t('dialogue.handle_succ'))
|
||||
}
|
||||
} finally {
|
||||
data.loading = false
|
||||
|
@ -133,18 +135,15 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
:bordered="false"
|
||||
:theme-overrides="{ borderRadius: '0px' }"
|
||||
:title="$t('log.title')"
|
||||
class="content-container flex-box-v"
|
||||
content-style="display: flex;flex-direction: column; overflow: hidden; backgroundColor: gray">
|
||||
<div class="content-log content-container content-value fill-height flex-box-v">
|
||||
<n-h3>{{ $t('log.title') }}</n-h3>
|
||||
<n-form :disabled="data.loading" class="flex-item" inline>
|
||||
<n-form-item :label="$t('log.filter_server')">
|
||||
<n-select
|
||||
v-model:value="data.server"
|
||||
:consistent-menu-width="false"
|
||||
:options="filterServerOption"
|
||||
:render-label="({ label, value }) => (value === '' ? $t(label) : label)"
|
||||
style="min-width: 100px" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('log.filter_keyword')">
|
||||
|
@ -157,24 +156,17 @@ defineExpose({
|
|||
<icon-button :icon="Delete" border t-tooltip="log.clean_log" @click="cleanHistory" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="content-value fill-height flex-box-h">
|
||||
<n-data-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data="data.history"
|
||||
:loading="data.loading"
|
||||
class="flex-item-expand"
|
||||
flex-height
|
||||
virtual-scroll />
|
||||
</div>
|
||||
</n-card>
|
||||
<n-data-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data="data.history"
|
||||
:loading="data.loading"
|
||||
class="flex-item-expand"
|
||||
flex-height
|
||||
virtual-scroll />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/content';
|
||||
|
||||
.content-container {
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@use '@/styles/content';
|
||||
</style>
|
||||
|
|
|
@ -12,9 +12,11 @@ import Detail from '@/components/icons/Detail.vue'
|
|||
import ContentValueWrapper from '@/components/content_value/ContentValueWrapper.vue'
|
||||
import ContentCli from '@/components/content_value/ContentCli.vue'
|
||||
import Monitor from '@/components/icons/Monitor.vue'
|
||||
import Pub from '@/components/icons/Pub.vue'
|
||||
import ContentSlog from '@/components/content_value/ContentSlog.vue'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import ContentMonitor from '@/components/content_value/ContentMonitor.vue'
|
||||
import { decodeRedisKey } from '@/utils/key_convert.js'
|
||||
import ContentPubsub from '@/components/content_value/ContentPubsub.vue'
|
||||
import Subscribe from '@/components/icons/Subscribe.vue'
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
|
@ -49,14 +51,14 @@ const tabContent = computed(() => {
|
|||
subTab: tab.subTab,
|
||||
type: toUpper(tab.type),
|
||||
db: tab.db,
|
||||
keyPath: tab.key,
|
||||
keyPath: tab.keyCode != null ? decodeRedisKey(tab.keyCode) : tab.key,
|
||||
keyCode: tab.keyCode,
|
||||
ttl: tab.ttl,
|
||||
value: tab.value,
|
||||
size: tab.size || 0,
|
||||
length: tab.length || 0,
|
||||
decode: tab.decode || decodeTypes.NONE,
|
||||
format: tab.format || formatTypes.RAW,
|
||||
decode: tab.decode,
|
||||
format: tab.format,
|
||||
matchPattern: tab.matchPattern || '',
|
||||
end: tab.end === true,
|
||||
loading: tab.loading === true,
|
||||
|
@ -68,7 +70,7 @@ const isBlankValue = computed(() => {
|
|||
})
|
||||
|
||||
const selectedSubTab = computed(() => {
|
||||
const { subTab = 'status' } = tabStore.currentTab || {}
|
||||
const { subTab = BrowserTabType.Status } = tabStore.currentTab || {}
|
||||
return subTab
|
||||
})
|
||||
|
||||
|
@ -102,7 +104,7 @@ watch(
|
|||
}"
|
||||
:value="selectedSubTab"
|
||||
class="content-sub-tab"
|
||||
default-value="status"
|
||||
:default-value="BrowserTabType.Status.toString()"
|
||||
pane-class="content-sub-tab-pane"
|
||||
placement="top"
|
||||
tab-style="padding-left: 10px; padding-right: 10px;"
|
||||
|
@ -121,7 +123,9 @@ watch(
|
|||
<span>{{ $t('interface.sub_tab.status') }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<content-server-status :server="props.server" />
|
||||
<content-server-status
|
||||
:pause="selectedSubTab !== BrowserTabType.Status.toString()"
|
||||
:server="props.server" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- key detail pane -->
|
||||
|
@ -157,7 +161,7 @@ watch(
|
|||
</n-tab-pane>
|
||||
|
||||
<!-- slow log pane -->
|
||||
<n-tab-pane :name="BrowserTabType.SlowLog.toString()" display-directive="if">
|
||||
<n-tab-pane :name="BrowserTabType.SlowLog.toString()" display-directive="show:lazy">
|
||||
<template #tab>
|
||||
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
|
||||
<n-icon size="16">
|
||||
|
@ -169,11 +173,11 @@ watch(
|
|||
<span>{{ $t('interface.sub_tab.slow_log') }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<content-slog :db="tabContent.db" :server="props.server" />
|
||||
<content-slog :server="props.server" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- command monitor pane -->
|
||||
<n-tab-pane :disabled="true" :name="BrowserTabType.CmdMonitor.toString()" display-directive="if">
|
||||
<n-tab-pane :name="BrowserTabType.CmdMonitor.toString()" display-directive="show:lazy">
|
||||
<template #tab>
|
||||
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
|
||||
<n-icon size="16">
|
||||
|
@ -185,14 +189,15 @@ watch(
|
|||
<span>{{ $t('interface.sub_tab.cmd_monitor') }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<content-monitor :server="props.server" />
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- pub/sub message pane -->
|
||||
<n-tab-pane :disabled="true" :name="BrowserTabType.PubMessage.toString()">
|
||||
<n-tab-pane :name="BrowserTabType.PubMessage.toString()" display-directive="show:lazy">
|
||||
<template #tab>
|
||||
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
|
||||
<n-icon size="16">
|
||||
<pub
|
||||
<subscribe
|
||||
:inverse="selectedSubTab === BrowserTabType.PubMessage.toString()"
|
||||
:stroke-color="themeVars.tabColor"
|
||||
stroke-width="4" />
|
||||
|
@ -200,13 +205,14 @@ watch(
|
|||
<span>{{ $t('interface.sub_tab.pub_message') }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<content-pubsub :server="props.server" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/content';
|
||||
@use '@/styles/content';
|
||||
|
||||
.content-container {
|
||||
//padding: 5px 5px 0;
|
||||
|
|
|
@ -1,8 +1,32 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import AddLink from '@/components/icons/AddLink.vue'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import { NButton, useThemeVars } from 'naive-ui'
|
||||
import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'
|
||||
import { find, includes, isEmpty } from 'lodash'
|
||||
import usePreferencesStore from 'stores/preferences.js'
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
const dialogStore = useDialogStore()
|
||||
const prefStore = usePreferencesStore()
|
||||
|
||||
const onOpenSponsor = (link) => {
|
||||
BrowserOpenURL(link)
|
||||
}
|
||||
|
||||
const sponsorAd = computed(() => {
|
||||
try {
|
||||
const content = localStorage.getItem('sponsor_ad')
|
||||
const ads = JSON.parse(content)
|
||||
const ad = find(ads, ({ region }) => {
|
||||
return isEmpty(region) || includes(region, prefStore.currentLanguage)
|
||||
})
|
||||
return ad || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -18,16 +42,27 @@ const dialogStore = useDialogStore()
|
|||
</n-button>
|
||||
</template>
|
||||
</n-empty>
|
||||
|
||||
<n-button v-if="sponsorAd != null" class="sponsor-ad" style="" text @click="onOpenSponsor(sponsorAd.link)">
|
||||
{{ sponsorAd.name }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/content';
|
||||
@use '@/styles/content';
|
||||
|
||||
.content-container {
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
|
||||
& > .sponsor-ad {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
vertical-align: bottom;
|
||||
color: v-bind('themeVars.textColor3');
|
||||
}
|
||||
}
|
||||
|
||||
.color-preset-item {
|
||||
|
|
|
@ -2,32 +2,24 @@
|
|||
import Server from '@/components/icons/Server.vue'
|
||||
import useTabStore from 'stores/tab.js'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { get, map } from 'lodash'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import useConnectionStore from 'stores/connections.js'
|
||||
import { extraTheme } from '@/utils/extra_theme.js'
|
||||
import usePreferencesStore from 'stores/preferences.js'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
|
||||
/**
|
||||
* Value content tab on head
|
||||
*/
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
const i18n = useI18n()
|
||||
const tabStore = useTabStore()
|
||||
const connectionStore = useConnectionStore()
|
||||
const browserStore = useBrowserStore()
|
||||
const prefStore = usePreferencesStore()
|
||||
|
||||
const onCloseTab = (tabIndex) => {
|
||||
const tab = get(tabStore.tabs, tabIndex)
|
||||
if (tab != null) {
|
||||
$dialog.warning(i18n.t('dialogue.close_confirm', { name: tab.name }), () => {
|
||||
browserStore.closeConnection(tab.name)
|
||||
})
|
||||
}
|
||||
tabStore.closeTab(tab.name)
|
||||
}
|
||||
|
||||
const tabMarkColor = computed(() => {
|
||||
|
|
|
@ -32,9 +32,12 @@ let fitAddonInst = null
|
|||
* @return {{fitAddon: xterm-addon-fit.FitAddon, term: Terminal}}
|
||||
*/
|
||||
const newTerm = () => {
|
||||
const { fontSize = 14, fontFamily = 'Courier New' } = prefStore.cliFont
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
fontSize: prefStore.general.fontSize || 14,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
cursorStyle: prefStore.cli.cursorStyle || 'block',
|
||||
cursorBlink: true,
|
||||
disableStdin: false,
|
||||
screenReaderMode: true,
|
||||
|
@ -51,6 +54,7 @@ const newTerm = () => {
|
|||
term.loadAddon(fitAddon)
|
||||
|
||||
term.onData(onTermData)
|
||||
term.attachCustomKeyEventHandler(onTermKey)
|
||||
return { term, fitAddon }
|
||||
}
|
||||
|
||||
|
@ -89,10 +93,21 @@ defineExpose({
|
|||
})
|
||||
|
||||
watch(
|
||||
() => prefStore.general.fontSize,
|
||||
(fontSize) => {
|
||||
() => prefStore.cliFont,
|
||||
({ fontSize = 14, fontFamily = 'Courier New' }) => {
|
||||
if (termInst != null) {
|
||||
termInst.options.fontSize = fontSize
|
||||
termInst.options.fontFamily = fontFamily
|
||||
}
|
||||
resizeTerm()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => prefStore.cli.cursorStyle,
|
||||
(style) => {
|
||||
if (termInst != null) {
|
||||
termInst.options.cursorStyle = style || 'block'
|
||||
}
|
||||
resizeTerm()
|
||||
},
|
||||
|
@ -177,6 +192,81 @@ const onTermData = (data) => {
|
|||
// term.write(data)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param e
|
||||
* @return {boolean}
|
||||
*/
|
||||
const onTermKey = (e) => {
|
||||
if (e.type === 'keydown') {
|
||||
if (e.ctrlKey) {
|
||||
switch (e.key) {
|
||||
case 'a': // move to head of line
|
||||
moveInputCursorTo(0)
|
||||
return false
|
||||
|
||||
case 'e': // move to tail of line
|
||||
moveInputCursorTo(Number.MAX_SAFE_INTEGER)
|
||||
return false
|
||||
|
||||
case 'f': // move forward
|
||||
moveInputCursor(1)
|
||||
return false
|
||||
|
||||
case 'b': // move backward
|
||||
moveInputCursor(-1)
|
||||
return false
|
||||
|
||||
case 'd': // delete char
|
||||
deleteInput(false)
|
||||
return false
|
||||
|
||||
case 'h': // back delete
|
||||
deleteInput(true)
|
||||
return false
|
||||
|
||||
case 'u': // delete all text before cursor
|
||||
deleteInput2(false)
|
||||
return false
|
||||
|
||||
case 'k': // delete all text after cursor
|
||||
deleteInput2(true)
|
||||
return false
|
||||
|
||||
case 'w': // delete word before cursor
|
||||
deleteWord(false)
|
||||
return false
|
||||
|
||||
case 'p': // previous history
|
||||
changeHistory(true)
|
||||
return false
|
||||
|
||||
case 'n': // next history
|
||||
changeHistory(false)
|
||||
return false
|
||||
|
||||
case 'l': // clear screen
|
||||
termInst.clear()
|
||||
replaceTermInput()
|
||||
newInputLine()
|
||||
return false
|
||||
}
|
||||
// block all ctrl key combinations input
|
||||
return false
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case 'Home': // move to head of line
|
||||
moveInputCursorTo(0)
|
||||
return false
|
||||
case 'End': // move to tail of line
|
||||
moveInputCursorTo(Number.MAX_SAFE_INTEGER)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* move input cursor by step
|
||||
* @param {number} step above 0 indicate move right; 0 indicate move to last
|
||||
|
@ -200,18 +290,30 @@ const moveInputCursor = (step) => {
|
|||
inputCursor += step
|
||||
updateCursor = true
|
||||
}
|
||||
} else {
|
||||
// update cursor position only
|
||||
const currentLine = getCurrentInput()
|
||||
inputCursor = Math.max(0, currentLine.length)
|
||||
updateCursor = true
|
||||
}
|
||||
|
||||
if (updateCursor) {
|
||||
termInst.write(`\x1B[${prefixLen.value + inputCursor + 1}G`)
|
||||
moveInputCursorTo(inputCursor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* move cursor to the end of current line
|
||||
*/
|
||||
const moveInputCursorToEnd = () => {
|
||||
moveInputCursorTo(Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
|
||||
/**
|
||||
* move cursor to pos
|
||||
* @param {number} pos
|
||||
*/
|
||||
const moveInputCursorTo = (pos) => {
|
||||
const currentLine = getCurrentInput()
|
||||
inputCursor = Math.min(Math.max(0, pos), currentLine.length)
|
||||
termInst.write(`\x1B[${prefixLen.value + inputCursor + 1}G`)
|
||||
}
|
||||
|
||||
/**
|
||||
* update current input cache and refresh term
|
||||
* @param {string} data
|
||||
|
@ -220,6 +322,8 @@ const updateInput = (data) => {
|
|||
if (data == null || data.length <= 0) {
|
||||
return
|
||||
}
|
||||
// replace (Non-Breaking Space) with normal blank space
|
||||
data = data.replace(/\u00A0/g, ' ')
|
||||
|
||||
if (termInst == null) {
|
||||
return
|
||||
|
@ -229,8 +333,7 @@ const updateInput = (data) => {
|
|||
if (inputCursor < currentLine.length) {
|
||||
// insert
|
||||
currentLine = currentLine.substring(0, inputCursor) + data + currentLine.substring(inputCursor)
|
||||
replaceTermInput()
|
||||
termInst.write(currentLine)
|
||||
replaceTermInput(currentLine)
|
||||
moveInputCursor(data.length)
|
||||
} else {
|
||||
// append
|
||||
|
@ -260,15 +363,95 @@ const deleteInput = (back = false) => {
|
|||
currentLine = currentLine.substring(0, inputCursor) + currentLine.substring(inputCursor + 1)
|
||||
}
|
||||
} else {
|
||||
// delete last one
|
||||
currentLine = currentLine.slice(0, -1)
|
||||
inputCursor -= 1
|
||||
if (back) {
|
||||
// delete last one
|
||||
currentLine = currentLine.slice(0, -1)
|
||||
inputCursor -= 1
|
||||
}
|
||||
}
|
||||
|
||||
replaceTermInput()
|
||||
termInst.write(currentLine)
|
||||
replaceTermInput(currentLine)
|
||||
updateCurrentInput(currentLine)
|
||||
moveInputCursor(0)
|
||||
moveInputCursorTo(inputCursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* delete to the end
|
||||
* @param back
|
||||
*/
|
||||
const deleteInput2 = (back = false) => {
|
||||
if (termInst == null) {
|
||||
return
|
||||
}
|
||||
|
||||
let currentLine = getCurrentInput()
|
||||
if (back) {
|
||||
// delete until tail
|
||||
currentLine = currentLine.substring(0, inputCursor - 1)
|
||||
inputCursor = currentLine.length
|
||||
} else {
|
||||
// delete until head
|
||||
currentLine = currentLine.substring(inputCursor)
|
||||
inputCursor = 0
|
||||
}
|
||||
|
||||
replaceTermInput(currentLine)
|
||||
updateCurrentInput(currentLine)
|
||||
moveInputCursorTo(inputCursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* delete one word
|
||||
* @param back
|
||||
*/
|
||||
const deleteWord = (back = false) => {
|
||||
if (termInst == null) {
|
||||
return
|
||||
}
|
||||
|
||||
let currentLine = getCurrentInput()
|
||||
if (back) {
|
||||
const prefix = currentLine.substring(0, inputCursor)
|
||||
let firstNonChar = false
|
||||
let cursor = inputCursor
|
||||
while (cursor < currentLine.length) {
|
||||
const isChar =
|
||||
(currentLine[cursor] >= 'a' && currentLine[cursor] <= 'z') ||
|
||||
(currentLine[cursor] >= 'A' && currentLine[cursor] <= 'Z') ||
|
||||
(currentLine[cursor] >= '0' && currentLine[cursor] <= '9')
|
||||
if (!firstNonChar || isChar) {
|
||||
if (!isChar) {
|
||||
firstNonChar = true
|
||||
}
|
||||
cursor++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
currentLine = prefix + currentLine.substring(cursor)
|
||||
} else {
|
||||
const suffix = currentLine.substring(inputCursor)
|
||||
let firstNonChar = false
|
||||
while (inputCursor >= 0) {
|
||||
const isChar =
|
||||
(currentLine[inputCursor] >= 'a' && currentLine[inputCursor] <= 'z') ||
|
||||
(currentLine[inputCursor] >= 'A' && currentLine[inputCursor] <= 'Z') ||
|
||||
(currentLine[inputCursor] >= '0' && currentLine[inputCursor] <= '9')
|
||||
if (!firstNonChar || isChar) {
|
||||
if (!isChar) {
|
||||
firstNonChar = true
|
||||
}
|
||||
inputCursor--
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
currentLine = currentLine.substring(0, inputCursor) + suffix
|
||||
}
|
||||
|
||||
replaceTermInput(currentLine)
|
||||
updateCurrentInput(currentLine)
|
||||
moveInputCursorTo(inputCursor)
|
||||
}
|
||||
|
||||
const getCurrentInput = () => {
|
||||
|
@ -315,9 +498,8 @@ const changeHistory = (prev) => {
|
|||
return
|
||||
}
|
||||
|
||||
replaceTermInput()
|
||||
termInst.write(currentLine)
|
||||
moveInputCursor(0)
|
||||
replaceTermInput(currentLine)
|
||||
moveInputCursorToEnd()
|
||||
}
|
||||
|
||||
return null
|
||||
|
@ -329,7 +511,6 @@ const changeHistory = (prev) => {
|
|||
*/
|
||||
const flushTermInput = (flushCmd = false) => {
|
||||
const currentLine = getCurrentInput()
|
||||
console.log('===send cmd', currentLine, currentLine.length)
|
||||
EventsEmit(`cmd:input:${props.name}`, currentLine)
|
||||
inputCursor = 0
|
||||
// historyIndex = inputHistory.length
|
||||
|
@ -338,7 +519,7 @@ const flushTermInput = (flushCmd = false) => {
|
|||
|
||||
/**
|
||||
* clear current input line and replace with new content
|
||||
* @param {string} [content]
|
||||
* @param {string|null} [content]
|
||||
*/
|
||||
const replaceTermInput = (content = '') => {
|
||||
if (termInst == null) {
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import usePreferencesStore from 'stores/preferences.js'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'json',
|
||||
},
|
||||
readonly: {
|
||||
type: String,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
resetKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
offsetKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
keepOffset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reset', 'input', 'save'])
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
/** @type {HTMLElement|null} */
|
||||
const editorRef = ref(null)
|
||||
/** @type monaco.editor.IStandaloneCodeEditor */
|
||||
let editorNode = null
|
||||
const scrollOffset = { top: 0, left: 0 }
|
||||
|
||||
const updateScroll = () => {
|
||||
if (editorNode != null) {
|
||||
if (props.keepOffset && !isEmpty(props.offsetKey)) {
|
||||
editorNode.setScrollPosition({ scrollTop: scrollOffset.top, scrollLeft: scrollOffset.left })
|
||||
} else {
|
||||
// reset offset if not needed
|
||||
editorNode.setScrollPosition({ scrollTop: 0, scrollLeft: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destroyEditor = () => {
|
||||
if (editorNode != null && editorNode.dispose != null) {
|
||||
const model = editorNode.getModel()
|
||||
if (model != null) {
|
||||
model.dispose()
|
||||
}
|
||||
editorNode.dispose()
|
||||
editorNode = null
|
||||
}
|
||||
}
|
||||
|
||||
const readonlyValue = computed(() => {
|
||||
return props.readonly || props.loading
|
||||
})
|
||||
|
||||
const pref = usePreferencesStore()
|
||||
onMounted(async () => {
|
||||
if (editorRef.value != null) {
|
||||
const { fontSize, fontFamily = ['monaco'] } = pref.editorFont
|
||||
editorNode = monaco.editor.create(editorRef.value, {
|
||||
// value: props.content,
|
||||
theme: pref.isDark ? 'rdm-dark' : 'rdm-light',
|
||||
language: props.language,
|
||||
lineNumbers: pref.showLineNum ? 'on' : 'off',
|
||||
links: pref.editorLinks,
|
||||
readOnly: readonlyValue.value,
|
||||
colorDecorators: true,
|
||||
accessibilitySupport: 'off',
|
||||
wordWrap: 'on',
|
||||
tabSize: 2,
|
||||
folding: pref.showFolding,
|
||||
dragAndDrop: pref.dropText,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
scrollbar: {
|
||||
useShadows: false,
|
||||
verticalScrollbarSize: '10px',
|
||||
},
|
||||
// formatOnType: true,
|
||||
contextmenu: false,
|
||||
lineNumbersMinChars: 2,
|
||||
lineDecorationsWidth: 0,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
selectionHighlight: false,
|
||||
renderLineHighlight: 'gutter',
|
||||
})
|
||||
|
||||
// add shortcut for save
|
||||
editorNode.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, (event) => {
|
||||
emit('save')
|
||||
})
|
||||
|
||||
editorNode.onDidScrollChange((event) => {
|
||||
// save scroll offset when changes, ie. content changes
|
||||
if (props.keepOffset && !event.scrollHeightChanged) {
|
||||
scrollOffset.top = event.scrollTop
|
||||
scrollOffset.left = event.scrollLeft
|
||||
}
|
||||
})
|
||||
|
||||
editorNode.onDidLayoutChange((event) => {
|
||||
updateScroll()
|
||||
})
|
||||
|
||||
// editorNode.onDidChangeModelLanguageConfiguration(() => {
|
||||
// editorNode?.getAction('editor.action.formatDocument')?.run()
|
||||
// })
|
||||
|
||||
if (editorNode.onDidChangeModelContent) {
|
||||
editorNode.onDidChangeModelContent(() => {
|
||||
emit('input', editorNode.getValue())
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
async (content) => {
|
||||
if (editorNode != null) {
|
||||
editorNode.setValue(content)
|
||||
await nextTick(() => emit('reset', content))
|
||||
updateScroll()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
async () => {
|
||||
if (editorNode != null) {
|
||||
editorNode.setValue(props.content)
|
||||
await nextTick(() => emit('reset', props.content))
|
||||
updateScroll()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.offsetKey,
|
||||
() => {
|
||||
// reset scroll offset when key changed
|
||||
if (editorNode != null) {
|
||||
scrollOffset.top = 0
|
||||
scrollOffset.left = 0
|
||||
editorNode.setScrollPosition({ scrollTop: 0, scrollLeft: 0 })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => readonlyValue.value,
|
||||
(readOnly) => {
|
||||
if (editorNode != null) {
|
||||
editorNode.updateOptions({
|
||||
readOnly,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
(language) => {
|
||||
if (editorNode != null) {
|
||||
const model = editorNode.getModel()
|
||||
if (model != null) {
|
||||
monaco.editor.setModelLanguage(model, language)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => pref.isDark,
|
||||
(dark) => {
|
||||
if (editorNode != null) {
|
||||
editorNode.updateOptions({
|
||||
theme: dark ? 'rdm-dark' : 'rdm-light',
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => pref.editor,
|
||||
({ showLineNum = true, showFolding = true, dropText = true, links = true }) => {
|
||||
if (editorNode != null) {
|
||||
const { fontSize, fontFamily } = pref.editorFont
|
||||
editorNode.updateOptions({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
lineNumbers: showLineNum ? 'on' : 'off',
|
||||
folding: showFolding,
|
||||
dragAndDrop: dropText,
|
||||
links,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'editor-border': props.border === true }" style="position: relative">
|
||||
<div ref="editorRef" class="editor-inst" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-border {
|
||||
border: 1px solid v-bind('themeVars.borderColor');
|
||||
border-radius: v-bind('themeVars.borderRadius');
|
||||
padding: 3px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-inst {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
:deep(.line-numbers) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed, defineEmits, defineProps, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { computed, defineEmits, defineProps, nextTick, reactive, ref, watchEffect } from 'vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import Save from '@/components/icons/Save.vue'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
|
@ -11,13 +11,19 @@ import FullScreen from '@/components/icons/FullScreen.vue'
|
|||
import WindowClose from '@/components/icons/WindowClose.vue'
|
||||
import Pin from '@/components/icons/Pin.vue'
|
||||
import OffScreen from '@/components/icons/OffScreen.vue'
|
||||
import ContentEditor from '@/components/content_value/ContentEditor.vue'
|
||||
import { isEmpty, toString } from 'lodash'
|
||||
|
||||
const props = defineProps({
|
||||
keyPath: String,
|
||||
show: {
|
||||
type: Boolean,
|
||||
},
|
||||
field: {
|
||||
type: [String, Number],
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
type: [String, Array],
|
||||
},
|
||||
fieldLabel: {
|
||||
type: String,
|
||||
|
@ -51,15 +57,17 @@ const emit = defineEmits([
|
|||
'close',
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(val) => {
|
||||
if (val != null) {
|
||||
onFormatChanged()
|
||||
watchEffect(
|
||||
() => {
|
||||
if (props.show && !isEmpty(props.keyPath)) {
|
||||
onFormatChanged(props.decode, props.format)
|
||||
} else {
|
||||
viewAs.value = ''
|
||||
}
|
||||
},
|
||||
{
|
||||
flush: 'post',
|
||||
},
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
|
@ -79,27 +87,32 @@ const displayValue = computed(() => {
|
|||
}
|
||||
return viewAs.value
|
||||
})
|
||||
const editingContent = ref('')
|
||||
const enableSave = computed(() => {
|
||||
return toString(props.field) !== viewAs.field || editingContent.value !== viewAs.value
|
||||
})
|
||||
|
||||
const btnStyle = computed(() => ({
|
||||
padding: '3px',
|
||||
border: 'solid 1px #0000',
|
||||
borderRadius: '3px',
|
||||
}))
|
||||
|
||||
const pinBtnStyle = computed(() => ({
|
||||
padding: '3px',
|
||||
border: `solid 1px ${themeVars.value.borderColor}`,
|
||||
borderRadius: '3px',
|
||||
backgroundColor: themeVars.value.borderColor,
|
||||
}))
|
||||
const viewLanguage = computed(() => {
|
||||
switch (viewAs.format) {
|
||||
case formatTypes.JSON:
|
||||
case formatTypes.UNICODE_JSON:
|
||||
return 'json'
|
||||
case formatTypes.YAML:
|
||||
return 'yaml'
|
||||
case formatTypes.XML:
|
||||
return 'xml'
|
||||
default:
|
||||
return 'plaintext'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {decodeTypes} decode
|
||||
* @param {formatTypes} format
|
||||
* @param {decodeTypes|null} decode
|
||||
* @param {formatTypes|null} format
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
const onFormatChanged = async (decode = '', format = '') => {
|
||||
const onFormatChanged = async (decode = null, format = null) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const {
|
||||
|
@ -112,17 +125,18 @@ const onFormatChanged = async (decode = '', format = '') => {
|
|||
format,
|
||||
})
|
||||
viewAs.field = props.field + ''
|
||||
viewAs.value = value
|
||||
editingContent.value = viewAs.value = value
|
||||
viewAs.decode = decode || retDecode
|
||||
viewAs.format = format || retFormat
|
||||
emit('update:decode', viewAs.decode)
|
||||
emit('update:format', viewAs.format)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateValue = (value) => {
|
||||
// emit('update:value', value)
|
||||
viewAs.value = value
|
||||
const onInput = (content) => {
|
||||
editingContent.value = content
|
||||
}
|
||||
|
||||
const onToggleFullscreen = () => {
|
||||
|
@ -135,7 +149,7 @@ const onClose = () => {
|
|||
}
|
||||
|
||||
const onSave = () => {
|
||||
emit('save', viewAs.field, viewAs.value, viewAs.decode, viewAs.format)
|
||||
emit('save', viewAs.field, editingContent.value, viewAs.decode, viewAs.format)
|
||||
if (!isPin.value) {
|
||||
nextTick().then(onClose)
|
||||
}
|
||||
|
@ -143,9 +157,9 @@ const onSave = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-editor flex-box-v">
|
||||
<n-card :title="$t('interface.edit_row')" autofocus size="small" style="height: 100%">
|
||||
<div class="editor-content flex-box-v" style="height: 100%">
|
||||
<div v-show="show" class="entry-editor flex-box-v">
|
||||
<n-card :title="$t('interface.edit_row')" autofocus class="flex-item-expand" size="small">
|
||||
<div class="editor-content flex-box-v flex-item-expand">
|
||||
<!-- field -->
|
||||
<div class="editor-content-item flex-box-v">
|
||||
<div class="editor-content-item-label">{{ props.fieldLabel }}</div>
|
||||
|
@ -160,14 +174,15 @@ const onSave = () => {
|
|||
<!-- value -->
|
||||
<div class="editor-content-item flex-box-v flex-item-expand">
|
||||
<div class="editor-content-item-label">{{ props.valueLabel }}</div>
|
||||
<n-input
|
||||
:placeholder="props.value"
|
||||
:resizable="false"
|
||||
:value="displayValue"
|
||||
autofocus
|
||||
<content-editor
|
||||
:border="true"
|
||||
:content="displayValue"
|
||||
:key-path="viewAs.field"
|
||||
:language="viewLanguage"
|
||||
class="flex-item-expand"
|
||||
type="textarea"
|
||||
@update:value="onUpdateValue" />
|
||||
@input="onInput"
|
||||
@reset="onInput"
|
||||
@save="onSave" />
|
||||
<format-selector
|
||||
:decode="viewAs.decode"
|
||||
:format="viewAs.format"
|
||||
|
@ -178,21 +193,21 @@ const onSave = () => {
|
|||
<template #header-extra>
|
||||
<n-space :size="5">
|
||||
<icon-button
|
||||
:button-style="isPin ? pinBtnStyle : btnStyle"
|
||||
:button-class="{ 'pinable-btn': true, 'unpin-btn': !isPin, 'pin-btn': isPin }"
|
||||
:icon="Pin"
|
||||
:size="19"
|
||||
:t-tooltip="isPin ? 'interface.unpin_edit' : 'interface.pin_edit'"
|
||||
stroke-width="4"
|
||||
@click="isPin = !isPin" />
|
||||
<icon-button
|
||||
:button-style="btnStyle"
|
||||
:button-class="['pinable-btn', 'unpin-btn']"
|
||||
:icon="props.fullscreen ? OffScreen : FullScreen"
|
||||
:size="18"
|
||||
stroke-width="5"
|
||||
t-tooltip="interface.fullscreen"
|
||||
@click="onToggleFullscreen" />
|
||||
<icon-button
|
||||
:button-style="btnStyle"
|
||||
:button-class="['pinable-btn', 'unpin-btn']"
|
||||
:icon="WindowClose"
|
||||
:size="18"
|
||||
stroke-width="5"
|
||||
|
@ -202,7 +217,7 @@ const onSave = () => {
|
|||
</template>
|
||||
<template #action>
|
||||
<n-space :wrap="false" :wrap-item="false" justify="end">
|
||||
<n-button ghost @click="onSave">
|
||||
<n-button :disabled="!enableSave" :secondary="enableSave" type="primary" @click="onSave">
|
||||
<template #icon>
|
||||
<n-icon :component="Save" />
|
||||
</template>
|
||||
|
@ -232,7 +247,7 @@ const onSave = () => {
|
|||
}
|
||||
|
||||
&-label {
|
||||
line-height: 1.25;
|
||||
height: 18px;
|
||||
color: v-bind('themeVars.textColor3');
|
||||
font-size: 13px;
|
||||
padding: 5px 0;
|
||||
|
@ -244,11 +259,33 @@ const onSave = () => {
|
|||
}
|
||||
}
|
||||
|
||||
:deep(.n-card__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
:deep(.n-card__action) {
|
||||
padding: 5px 10px;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
:deep(.pinable-btn) {
|
||||
padding: 3px;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(.unpin-btn) {
|
||||
border-color: #0000;
|
||||
}
|
||||
|
||||
:deep(.pin-btn) {
|
||||
border-color: v-bind('themeVars.iconColorDisabled');
|
||||
background-color: v-bind('themeVars.iconColorDisabled');
|
||||
}
|
||||
|
||||
//:deep(.n-card--bordered) {
|
||||
// border-radius: 0;
|
||||
//}
|
||||
|
|
|
@ -0,0 +1,197 @@
|
|||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { debounce, filter, get, includes, isEmpty, join } from 'lodash'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import Play from '@/components/icons/Play.vue'
|
||||
import Pause from '@/components/icons/Pause.vue'
|
||||
import { ExportLog, StartMonitor, StopMonitor } from 'wailsjs/go/services/monitorService.js'
|
||||
import { EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'
|
||||
import Copy from '@/components/icons/Copy.vue'
|
||||
import Export from '@/components/icons/Export.vue'
|
||||
import Delete from '@/components/icons/Delete.vue'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
import Bottom from '@/components/icons/Bottom.vue'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const i18n = useI18n()
|
||||
const props = defineProps({
|
||||
server: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const data = reactive({
|
||||
monitorEvent: '',
|
||||
list: [],
|
||||
listLimit: 20,
|
||||
keyword: '',
|
||||
autoShowLast: true,
|
||||
})
|
||||
|
||||
const listRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
// try to stop prev monitor first
|
||||
onStopMonitor()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onStopMonitor()
|
||||
})
|
||||
|
||||
const isMonitoring = computed(() => {
|
||||
return !isEmpty(data.monitorEvent)
|
||||
})
|
||||
|
||||
const displayList = computed(() => {
|
||||
if (!isEmpty(data.keyword)) {
|
||||
return filter(data.list, (line) => includes(line, data.keyword))
|
||||
}
|
||||
return data.list
|
||||
})
|
||||
|
||||
const _scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
listRef.value?.scrollTo({ position: 'bottom' })
|
||||
})
|
||||
}
|
||||
const scrollToBottom = debounce(_scrollToBottom, 1000, { leading: true, trailing: true })
|
||||
|
||||
const onStartMonitor = async () => {
|
||||
if (isMonitoring.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const { data: ret, success, msg } = await StartMonitor(props.server)
|
||||
if (!success) {
|
||||
$message.error(msg)
|
||||
return
|
||||
}
|
||||
data.monitorEvent = get(ret, 'eventName')
|
||||
EventsOn(data.monitorEvent, (content) => {
|
||||
if (content instanceof Array) {
|
||||
data.list.push(...content)
|
||||
} else {
|
||||
data.list.push(content)
|
||||
}
|
||||
if (data.autoShowLast) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
const onStopMonitor = async () => {
|
||||
const { success, msg } = await StopMonitor(props.server)
|
||||
if (!success) {
|
||||
$message.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
EventsOff(data.monitorEvent)
|
||||
data.monitorEvent = ''
|
||||
}
|
||||
|
||||
const onCopyLog = async () => {
|
||||
copy(join(data.list, '\n'))
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
}
|
||||
|
||||
const onExportLog = () => {
|
||||
ExportLog(data.list)
|
||||
}
|
||||
|
||||
const onCleanLog = () => {
|
||||
data.list = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-log content-container fill-height flex-box-v">
|
||||
<n-form class="flex-item" label-align="left" label-placement="left" label-width="auto" size="small">
|
||||
<n-form-item :feedback="$t('monitor.warning')" :label="$t('monitor.actions')">
|
||||
<n-space :wrap="false" :wrap-item="false" style="width: 100%">
|
||||
<n-button
|
||||
v-if="!isMonitoring"
|
||||
:focusable="false"
|
||||
secondary
|
||||
strong
|
||||
type="success"
|
||||
@click="onStartMonitor">
|
||||
<template #icon>
|
||||
<n-icon :component="Play" size="18" />
|
||||
</template>
|
||||
{{ $t('monitor.start') }}
|
||||
</n-button>
|
||||
<n-button v-else :focusable="false" secondary strong type="warning" @click="onStopMonitor">
|
||||
<template #icon>
|
||||
<n-icon :component="Pause" size="18" />
|
||||
</template>
|
||||
{{ $t('monitor.stop') }}
|
||||
</n-button>
|
||||
<n-button-group>
|
||||
<icon-button
|
||||
:icon="Copy"
|
||||
border
|
||||
size="18"
|
||||
stroke-width="3.5"
|
||||
t-tooltip="monitor.copy_log"
|
||||
@click="onCopyLog" />
|
||||
<icon-button
|
||||
:icon="Export"
|
||||
border
|
||||
size="18"
|
||||
stroke-width="3.5"
|
||||
t-tooltip="monitor.save_log"
|
||||
@click="onExportLog" />
|
||||
</n-button-group>
|
||||
<icon-button
|
||||
:icon="Bottom"
|
||||
:secondary="data.autoShowLast"
|
||||
:type="data.autoShowLast ? 'primary' : 'default'"
|
||||
border
|
||||
size="18"
|
||||
stroke-width="3.5"
|
||||
t-tooltip="monitor.always_show_last"
|
||||
@click="data.autoShowLast = !data.autoShowLast" />
|
||||
<div class="flex-item-expand" />
|
||||
<icon-button
|
||||
:icon="Delete"
|
||||
border
|
||||
size="18"
|
||||
stroke-width="3.5"
|
||||
t-tooltip="monitor.clean_log"
|
||||
@click="onCleanLog" />
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('monitor.search')">
|
||||
<n-input v-model:value="data.keyword" clearable placeholder="" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-virtual-list ref="listRef" :item-size="25" :items="displayList" class="list-wrapper">
|
||||
<template #default="{ item }">
|
||||
<div class="line-item content-value">
|
||||
<b>></b>
|
||||
{{ item }}
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/content';
|
||||
|
||||
.line-item {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.list-wrapper {
|
||||
background-color: v-bind('themeVars.codeColor');
|
||||
border: solid 1px v-bind('themeVars.borderColor');
|
||||
border-radius: 3px;
|
||||
padding: 5px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,294 @@
|
|||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { debounce, get, isEmpty, size, uniq } from 'lodash'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import { EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'
|
||||
import dayjs from 'dayjs'
|
||||
import Publish from '@/components/icons/Publish.vue'
|
||||
import Subscribe from '@/components/icons/Subscribe.vue'
|
||||
import Pause from '@/components/icons/Pause.vue'
|
||||
import Delete from '@/components/icons/Delete.vue'
|
||||
import { Publish as PublishSend, StartSubscribe, StopSubscribe } from 'wailsjs/go/services/pubsubService.js'
|
||||
import Checked from '@/components/icons/Checked.vue'
|
||||
import Bottom from '@/components/icons/Bottom.vue'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const browserStore = useBrowserStore()
|
||||
const i18n = useI18n()
|
||||
const props = defineProps({
|
||||
server: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const data = reactive({
|
||||
subscribeEvent: '',
|
||||
list: [],
|
||||
keyword: '',
|
||||
autoShowLast: true,
|
||||
ellipsisMessage: false,
|
||||
channelHistory: [],
|
||||
})
|
||||
|
||||
const publishData = reactive({
|
||||
channel: '',
|
||||
message: '',
|
||||
received: 0,
|
||||
lastShowReceived: -1,
|
||||
})
|
||||
|
||||
const tableRef = ref(null)
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: () => i18n.t('pubsub.time'),
|
||||
key: 'timestamp',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: ({ timestamp }, index) => {
|
||||
return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: () => i18n.t('pubsub.channel'),
|
||||
key: 'channel',
|
||||
filterOptionValue: data.client,
|
||||
resizable: true,
|
||||
filter: (value, row) => {
|
||||
return value === '' || row.client === value.toString() || row.addr === value.toString()
|
||||
},
|
||||
width: 200,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
ellipsis: {
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: () => i18n.t('pubsub.message'),
|
||||
key: 'message',
|
||||
titleAlign: 'center',
|
||||
filterOptionValue: data.keyword,
|
||||
resizable: true,
|
||||
className: 'content-value',
|
||||
ellipsis: data.ellipsisMessage
|
||||
? {
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
filter: (value, row) => {
|
||||
return value === '' || !!~row.cmd.indexOf(value.toString())
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
// try to stop prev subscribe first
|
||||
onStopSubscribe()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onStopSubscribe()
|
||||
})
|
||||
|
||||
const isSubscribing = computed(() => {
|
||||
return !isEmpty(data.subscribeEvent)
|
||||
})
|
||||
|
||||
const publishEnable = computed(() => {
|
||||
return !isEmpty(publishData.channel)
|
||||
})
|
||||
|
||||
const _scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
tableRef.value?.scrollTo({ position: 'bottom' })
|
||||
})
|
||||
}
|
||||
const scrollToBottom = debounce(_scrollToBottom, 300, { leading: true, trailing: true })
|
||||
|
||||
const onStartSubscribe = async () => {
|
||||
if (isSubscribing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const { data: ret, success, msg } = await StartSubscribe(props.server)
|
||||
if (!success) {
|
||||
$message.error(msg)
|
||||
return
|
||||
}
|
||||
data.subscribeEvent = get(ret, 'eventName')
|
||||
EventsOn(data.subscribeEvent, (content) => {
|
||||
if (content instanceof Array) {
|
||||
data.list.push(...content)
|
||||
} else {
|
||||
data.list.push(content)
|
||||
}
|
||||
if (data.autoShowLast) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
const onStopSubscribe = async () => {
|
||||
const { success, msg } = await StopSubscribe(props.server)
|
||||
if (!success) {
|
||||
$message.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
EventsOff(data.subscribeEvent)
|
||||
data.subscribeEvent = ''
|
||||
}
|
||||
|
||||
const onCleanLog = () => {
|
||||
data.list = []
|
||||
}
|
||||
|
||||
const onPublish = async () => {
|
||||
if (isEmpty(publishData.channel)) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
success,
|
||||
msg,
|
||||
data: { received = 0 },
|
||||
} = await PublishSend(props.server, publishData.channel, publishData.message || '')
|
||||
if (!success) {
|
||||
publishData.received = 0
|
||||
if (!isEmpty(msg)) {
|
||||
$message.error(msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
publishData.message = ''
|
||||
publishData.received = received
|
||||
publishData.lastShowReceived = Date.now()
|
||||
// save channel history
|
||||
data.channelHistory = uniq(data.channelHistory.concat(publishData.channel))
|
||||
|
||||
// hide send status after 2 seconds
|
||||
setTimeout(() => {
|
||||
if (publishData.lastShowReceived > 0 && Date.now() - publishData.lastShowReceived > 2000) {
|
||||
publishData.lastShowReceived = -1
|
||||
}
|
||||
}, 2100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-log content-container fill-height flex-box-v">
|
||||
<n-form class="flex-item" label-align="left" label-placement="left" label-width="auto" size="small">
|
||||
<n-form-item :show-label="false">
|
||||
<n-space :wrap="false" :wrap-item="false" style="width: 100%">
|
||||
<n-button
|
||||
v-if="!isSubscribing"
|
||||
:focusable="false"
|
||||
secondary
|
||||
strong
|
||||
type="success"
|
||||
@click="onStartSubscribe">
|
||||
<template #icon>
|
||||
<n-icon :component="Subscribe" size="18" />
|
||||
</template>
|
||||
{{ $t('pubsub.subscribe') }}
|
||||
</n-button>
|
||||
<n-button v-else :focusable="false" secondary strong type="warning" @click="onStopSubscribe">
|
||||
<template #icon>
|
||||
<n-icon :component="Pause" size="18" />
|
||||
</template>
|
||||
{{ $t('pubsub.unsubscribe') }}
|
||||
</n-button>
|
||||
<icon-button
|
||||
:icon="Bottom"
|
||||
:secondary="data.autoShowLast"
|
||||
:type="data.autoShowLast ? 'primary' : 'default'"
|
||||
border
|
||||
size="18"
|
||||
stroke-width="3.5"
|
||||
t-tooltip="monitor.always_show_last"
|
||||
@click="data.autoShowLast = !data.autoShowLast" />
|
||||
<div class="flex-item-expand" />
|
||||
<icon-button
|
||||
:icon="Delete"
|
||||
border
|
||||
size="18"
|
||||
stroke-width="3.5"
|
||||
t-tooltip="pubsub.clear"
|
||||
@click="onCleanLog" />
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-data-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data="data.list"
|
||||
:loading="data.loading"
|
||||
class="flex-item-expand"
|
||||
flex-height
|
||||
size="small"
|
||||
virtual-scroll />
|
||||
<div class="total-message">{{ $t('pubsub.receive_message', { total: size(data.list) }) }}</div>
|
||||
<div class="flex-box-h publish-input">
|
||||
<n-input-group>
|
||||
<n-auto-complete
|
||||
v-model:value="publishData.channel"
|
||||
:get-show="() => true"
|
||||
:options="data.channelHistory"
|
||||
:placeholder="$t('pubsub.channel')"
|
||||
style="width: 35%; max-width: 200px"
|
||||
@keydown.enter="onPublish" />
|
||||
<n-input
|
||||
v-model:value="publishData.message"
|
||||
:placeholder="$t('pubsub.message')"
|
||||
@keydown.enter="onPublish">
|
||||
<template #suffix>
|
||||
<transition mode="out-in" name="fade">
|
||||
<n-tag v-show="publishData.lastShowReceived > 0" bordered size="small" type="success">
|
||||
<template #icon>
|
||||
<n-icon :component="Checked" size="16" />
|
||||
</template>
|
||||
{{ publishData.received }}
|
||||
</n-tag>
|
||||
</transition>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-input-group>
|
||||
<n-button :disabled="!publishEnable" type="info" @click="onPublish">
|
||||
<template #icon>
|
||||
<n-icon :component="Publish" size="18" />
|
||||
</template>
|
||||
{{ $t('pubsub.publish') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/content';
|
||||
|
||||
.total-message {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.publish-input {
|
||||
margin: 10px 0 0;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
|
@ -1,17 +1,43 @@
|
|||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, nextTick, reactive } from 'vue'
|
||||
import { debounce, isEmpty, trim } from 'lodash'
|
||||
import { NButton, NInput } from 'naive-ui'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
import SpellCheck from '@/components/icons/SpellCheck.vue'
|
||||
|
||||
const emit = defineEmits(['filterChanged', 'matchChanged'])
|
||||
const props = defineProps({
|
||||
fullSearchIcon: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
debounceWait: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
useGlob: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['filterChanged', 'matchChanged', 'exactChanged'])
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {UnwrapNestedRefs<{filter: string, match: string}>}
|
||||
* @type {UnwrapNestedRefs<{filter: string, match: string, exact: boolean}>}
|
||||
*/
|
||||
const inputData = reactive({
|
||||
match: '',
|
||||
filter: '',
|
||||
exact: false,
|
||||
})
|
||||
|
||||
const hasMatch = computed(() => {
|
||||
|
@ -22,32 +48,49 @@ const hasFilter = computed(() => {
|
|||
return !isEmpty(trim(inputData.filter))
|
||||
})
|
||||
|
||||
const onExactChecked = () => {
|
||||
// update search search result
|
||||
if (hasMatch.value) {
|
||||
nextTick(() => onForceFullSearch())
|
||||
}
|
||||
}
|
||||
|
||||
const onFullSearch = () => {
|
||||
inputData.filter = trim(inputData.filter)
|
||||
if (!isEmpty(inputData.filter)) {
|
||||
inputData.match = inputData.filter
|
||||
inputData.filter = ''
|
||||
emit('matchChanged', inputData.match, inputData.filter)
|
||||
emit('matchChanged', inputData.match, inputData.filter, inputData.exact)
|
||||
}
|
||||
}
|
||||
|
||||
const _onInput = () => {
|
||||
emit('filterChanged', inputData.filter)
|
||||
const onForceFullSearch = () => {
|
||||
inputData.filter = trim(inputData.filter)
|
||||
emit('matchChanged', inputData.match, inputData.filter, inputData.exact)
|
||||
}
|
||||
const onInput = debounce(_onInput, 500, { leading: true, trailing: true })
|
||||
|
||||
const _onInput = () => {
|
||||
emit('filterChanged', inputData.filter, inputData.exact)
|
||||
}
|
||||
const onInput = debounce(_onInput, props.debounceWait, { leading: true, trailing: true })
|
||||
|
||||
const onClearFilter = () => {
|
||||
inputData.filter = ''
|
||||
onClearMatch()
|
||||
}
|
||||
|
||||
const onUpdateMatch = () => {
|
||||
inputData.filter = inputData.match
|
||||
onClearMatch()
|
||||
}
|
||||
|
||||
const onClearMatch = () => {
|
||||
const changed = !isEmpty(inputData.match)
|
||||
inputData.match = ''
|
||||
if (changed) {
|
||||
emit('matchChanged', inputData.match, inputData.filter)
|
||||
emit('matchChanged', inputData.match, inputData.filter, inputData.exact)
|
||||
} else {
|
||||
emit('filterChanged', inputData.filter)
|
||||
emit('filterChanged', inputData.filter, inputData.exact)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,28 +100,97 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-input-group>
|
||||
<n-input-group style="overflow: hidden">
|
||||
<slot name="prepend" />
|
||||
<n-input
|
||||
v-model:value="inputData.filter"
|
||||
:placeholder="$t('interface.filter')"
|
||||
:size="props.small ? 'small' : ''"
|
||||
:theme-overrides="{ paddingSmall: '0 3px', paddingMedium: '0 6px' }"
|
||||
clearable
|
||||
@clear="onClearFilter"
|
||||
@input="onInput">
|
||||
@input="onInput"
|
||||
@keyup.enter="onFullSearch">
|
||||
<template #prefix>
|
||||
<n-tooltip v-if="hasMatch">
|
||||
<slot name="prefix" />
|
||||
<n-tooltip v-if="hasMatch" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-tag closable size="small" @close="onClearMatch">
|
||||
<n-tag closable size="small" @close="onClearMatch" @dblclick="onUpdateMatch">
|
||||
{{ inputData.match }}
|
||||
</n-tag>
|
||||
</template>
|
||||
{{ $t('interface.full_search_result', { pattern: inputData.match }) }}
|
||||
{{
|
||||
$t('interface.full_search_result', {
|
||||
pattern: props.useGlob ? inputData.match : '*' + inputData.match + '*',
|
||||
})
|
||||
}}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<template v-if="props.useGlob">
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag
|
||||
v-model:checked="inputData.exact"
|
||||
:checkable="true"
|
||||
:type="props.exact ? 'primary' : 'default'"
|
||||
size="small"
|
||||
strong
|
||||
style="padding: 0 5px"
|
||||
@updateChecked="onExactChecked">
|
||||
<n-icon :size="14">
|
||||
<spell-check :stroke-width="2" />
|
||||
</n-icon>
|
||||
</n-tag>
|
||||
</template>
|
||||
<div class="text-block" style="max-width: 600px">
|
||||
{{ $t('dialogue.filter.exact_match_tip') }}
|
||||
</div>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</n-input>
|
||||
<n-button :disabled="hasMatch && !hasFilter" :focusable="false" @click="onFullSearch">
|
||||
|
||||
<icon-button
|
||||
v-if="props.fullSearchIcon"
|
||||
:disabled="hasMatch && !hasFilter"
|
||||
:icon="props.fullSearchIcon"
|
||||
:size="small ? 16 : 20"
|
||||
:tooltip-delay="1"
|
||||
border
|
||||
small
|
||||
stroke-width="4"
|
||||
@click="onFullSearch">
|
||||
<template #tooltip>
|
||||
<div class="text-block" style="max-width: 600px">
|
||||
{{ $t('dialogue.filter.filter_pattern_tip') }}
|
||||
</div>
|
||||
</template>
|
||||
</icon-button>
|
||||
<n-button v-else :disabled="hasMatch && !hasFilter" :focusable="false" @click="onFullSearch">
|
||||
{{ $t('interface.full_search') }}
|
||||
</n-button>
|
||||
<slot name="append" />
|
||||
</n-input-group>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.n-input) {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.n-input__prefix) {
|
||||
max-width: 50%;
|
||||
|
||||
& > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-tag__content) {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,20 +1,71 @@
|
|||
<script setup>
|
||||
import { get, isEmpty, map, mapValues, pickBy, split, sum, toArray, toNumber } from 'lodash'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
cloneDeep,
|
||||
flatMap,
|
||||
get,
|
||||
isEmpty,
|
||||
map,
|
||||
mapValues,
|
||||
pickBy,
|
||||
random,
|
||||
slice,
|
||||
split,
|
||||
sum,
|
||||
toArray,
|
||||
toNumber,
|
||||
} from 'lodash'
|
||||
import { computed, h, onMounted, onUnmounted, reactive, ref, shallowRef, toRaw, watch } from 'vue'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
import Filter from '@/components/icons/Filter.vue'
|
||||
import Refresh from '@/components/icons/Refresh.vue'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import { timeout } from '@/utils/promise.js'
|
||||
import AutoRefreshForm from '@/components/common/AutoRefreshForm.vue'
|
||||
import { NButton, NIcon, NSpace, useThemeVars } from 'naive-ui'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { convertBytes, formatBytes } from '@/utils/byte_convert.js'
|
||||
import usePreferencesStore from 'stores/preferences.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useConnectionStore from 'stores/connections.js'
|
||||
import { toHumanReadable } from '@/utils/date.js'
|
||||
|
||||
const props = defineProps({
|
||||
server: String,
|
||||
pause: Boolean,
|
||||
})
|
||||
|
||||
const browserStore = useBrowserStore()
|
||||
const prefStore = usePreferencesStore()
|
||||
const connectionStore = useConnectionStore()
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
const serverInfo = ref({})
|
||||
const autoRefresh = ref(false)
|
||||
const loading = ref(false) // loading status for refresh
|
||||
const autoLoading = ref(false) // loading status for auto refresh
|
||||
const pageState = reactive({
|
||||
autoRefresh: false,
|
||||
refreshInterval: 5,
|
||||
loading: false, // loading status for refresh
|
||||
autoLoading: false, // loading status for auto refresh
|
||||
})
|
||||
const statusHistory = 5
|
||||
|
||||
/**
|
||||
*
|
||||
* @param origin
|
||||
* @param {string[]} [labels]
|
||||
* @param {number[][]} [datalist]
|
||||
* @return {unknown}
|
||||
*/
|
||||
const generateData = (origin, labels, datalist) => {
|
||||
let ret = toRaw(origin)
|
||||
ret.labels = labels || ret.labels
|
||||
if (datalist && datalist.length > 0) {
|
||||
for (let i = 0; i < datalist.length; i++) {
|
||||
ret.datasets[i].data = datalist[i]
|
||||
}
|
||||
}
|
||||
return cloneDeep(ret)
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh server status info
|
||||
|
@ -23,35 +74,182 @@ const autoLoading = ref(false) // loading status for auto refresh
|
|||
*/
|
||||
const refreshInfo = async (force) => {
|
||||
if (force) {
|
||||
loading.value = true
|
||||
pageState.loading = true
|
||||
} else {
|
||||
autoLoading.value = true
|
||||
pageState.autoLoading = true
|
||||
}
|
||||
if (!isEmpty(props.server) && browserStore.isConnected(props.server)) {
|
||||
try {
|
||||
serverInfo.value = await browserStore.getServerInfo(props.server)
|
||||
const info = await browserStore.getServerInfo(props.server, true)
|
||||
if (!isEmpty(info)) {
|
||||
serverInfo.value = info
|
||||
_updateChart(info)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
autoLoading.value = false
|
||||
pageState.loading = false
|
||||
pageState.autoLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let intervalID
|
||||
onMounted(() => {
|
||||
refreshInfo()
|
||||
intervalID = setInterval(() => {
|
||||
if (autoRefresh.value === true) {
|
||||
refreshInfo()
|
||||
const _updateChart = (info) => {
|
||||
let timeLabels = toRaw(cmdRate.value.labels)
|
||||
timeLabels = timeLabels.concat(dayjs().format('HH:mm:ss'))
|
||||
timeLabels = slice(timeLabels, Math.max(0, timeLabels.length - statusHistory))
|
||||
|
||||
// commands per seconds
|
||||
{
|
||||
let dataset = toRaw(cmdRate.value.datasets[0].data)
|
||||
const cmd = parseInt(get(info, 'Stats.instantaneous_ops_per_sec', '0'))
|
||||
dataset = dataset.concat(cmd)
|
||||
dataset = slice(dataset, Math.max(0, dataset.length - statusHistory))
|
||||
cmdRate.value = generateData(cmdRate.value, timeLabels, [dataset])
|
||||
}
|
||||
|
||||
// connected clients
|
||||
{
|
||||
let dataset = toRaw(connectedClients.value.datasets[0].data)
|
||||
const count = parseInt(get(info, 'Clients.connected_clients', '0'))
|
||||
dataset = dataset.concat(count)
|
||||
dataset = slice(dataset, Math.max(0, dataset.length - statusHistory))
|
||||
connectedClients.value = generateData(connectedClients.value, timeLabels, [dataset])
|
||||
}
|
||||
|
||||
// memory usage
|
||||
{
|
||||
let dataset = toRaw(memoryUsage.value.datasets[0].data)
|
||||
let size = parseInt(get(info, 'Memory.used_memory', '0'))
|
||||
dataset = dataset.concat(size)
|
||||
dataset = slice(dataset, Math.max(0, dataset.length - statusHistory))
|
||||
memoryUsage.value = generateData(memoryUsage.value, timeLabels, [dataset])
|
||||
}
|
||||
|
||||
// network input/output rate
|
||||
{
|
||||
let dataset1 = toRaw(networkRate.value.datasets[0].data)
|
||||
const input = parseInt(get(info, 'Stats.instantaneous_input_kbps', '0'))
|
||||
dataset1 = dataset1.concat(input)
|
||||
dataset1 = slice(dataset1, Math.max(0, dataset1.length - statusHistory))
|
||||
|
||||
let dataset2 = toRaw(networkRate.value.datasets[1].data)
|
||||
const output = parseInt(get(info, 'Stats.instantaneous_output_kbps', '0'))
|
||||
dataset2 = dataset2.concat(output)
|
||||
dataset2 = slice(dataset2, Math.max(0, dataset2.length - statusHistory))
|
||||
networkRate.value = generateData(networkRate.value, timeLabels, [dataset1, dataset2])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* for mock activity data only
|
||||
* @private
|
||||
*/
|
||||
const _mockChart = () => {
|
||||
const timeLabels = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timeLabels.push(dayjs().add(5, 'seconds').format('HH:mm:ss'))
|
||||
}
|
||||
|
||||
// commands per seconds
|
||||
{
|
||||
const dataset = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
dataset.push(random(10, 200))
|
||||
}
|
||||
}, 5000)
|
||||
cmdRate.value = generateData(cmdRate.value, timeLabels, [dataset])
|
||||
}
|
||||
|
||||
// connected clients
|
||||
{
|
||||
const dataset = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
dataset.push(random(10, 20))
|
||||
}
|
||||
connectedClients.value = generateData(connectedClients.value, timeLabels, [dataset])
|
||||
}
|
||||
|
||||
// memory usage
|
||||
{
|
||||
const dataset = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
dataset.push(random(120 * 1024 * 1024, 200 * 1024 * 1024))
|
||||
}
|
||||
memoryUsage.value = generateData(memoryUsage.value, timeLabels, [dataset])
|
||||
}
|
||||
|
||||
// network input/output rate
|
||||
{
|
||||
const dataset1 = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
dataset1.push(random(100, 1500))
|
||||
}
|
||||
|
||||
const dataset2 = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
dataset2.push(random(200, 3000))
|
||||
}
|
||||
|
||||
networkRate.value = generateData(networkRate.value, timeLabels, [dataset1, dataset2])
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return pageState.loading || pageState.autoLoading
|
||||
})
|
||||
|
||||
const startAutoRefresh = async () => {
|
||||
// connectionStore.getRefreshInterval()
|
||||
let lastExec = Date.now()
|
||||
do {
|
||||
if (!pageState.autoRefresh) {
|
||||
break
|
||||
}
|
||||
await timeout(100)
|
||||
if (
|
||||
props.pause ||
|
||||
pageState.loading ||
|
||||
pageState.autoLoading ||
|
||||
Date.now() - lastExec < pageState.refreshInterval * 1000
|
||||
) {
|
||||
continue
|
||||
}
|
||||
lastExec = Date.now()
|
||||
await refreshInfo()
|
||||
} while (true)
|
||||
stopAutoRefresh()
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
pageState.autoRefresh = false
|
||||
}
|
||||
|
||||
const onToggleRefresh = (on) => {
|
||||
if (on) {
|
||||
tabVal.value = 'activity'
|
||||
connectionStore.saveRefreshInterval(props.server, pageState.refreshInterval || 5)
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
connectionStore.saveRefreshInterval(props.server, -1)
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const interval = connectionStore.getRefreshInterval(props.server)
|
||||
if (interval >= 0) {
|
||||
pageState.autoRefresh = true
|
||||
pageState.refreshInterval = interval === 0 ? 5 : interval
|
||||
onToggleRefresh(true)
|
||||
} else {
|
||||
setTimeout(refreshInfo, 3000)
|
||||
// setTimeout(_mockChart, 1000)
|
||||
}
|
||||
refreshInfo()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalID)
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
const scrollRef = ref(null)
|
||||
const redisVersion = computed(() => {
|
||||
return get(serverInfo.value, 'Server.redis_version', '')
|
||||
})
|
||||
|
@ -66,31 +264,24 @@ const role = computed(() => {
|
|||
|
||||
const timeUnit = ['common.unit_minute', 'common.unit_hour', 'common.unit_day']
|
||||
const uptime = computed(() => {
|
||||
let seconds = get(serverInfo.value, 'Server.uptime_in_seconds', 0)
|
||||
let seconds = parseInt(get(serverInfo.value, 'Server.uptime_in_seconds', '0'))
|
||||
seconds /= 60
|
||||
if (seconds < 60) {
|
||||
// minutes
|
||||
return [Math.floor(seconds), timeUnit[0]]
|
||||
return { value: Math.floor(seconds), unit: timeUnit[0] }
|
||||
}
|
||||
seconds /= 60
|
||||
if (seconds < 60) {
|
||||
// hours
|
||||
return [Math.floor(seconds), timeUnit[1]]
|
||||
return { value: Math.floor(seconds), unit: timeUnit[1] }
|
||||
}
|
||||
return [Math.floor(seconds / 24), timeUnit[2]]
|
||||
return { value: Math.floor(seconds / 24), unit: timeUnit[2] }
|
||||
})
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const usedMemory = computed(() => {
|
||||
let size = get(serverInfo.value, 'Memory.used_memory', 0)
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return [size.toFixed(2), units[unitIndex]]
|
||||
let size = parseInt(get(serverInfo.value, 'Memory.used_memory', '0'))
|
||||
const { value, unit } = convertBytes(size)
|
||||
return [value, unit]
|
||||
})
|
||||
|
||||
const totalKeys = computed(() => {
|
||||
|
@ -105,118 +296,503 @@ const totalKeys = computed(() => {
|
|||
})
|
||||
return sum(toArray(nums))
|
||||
})
|
||||
const infoFilter = ref('')
|
||||
|
||||
const tabVal = ref('activity')
|
||||
const infoFilter = reactive({
|
||||
keyword: '',
|
||||
group: 'CPU',
|
||||
})
|
||||
|
||||
const info = computed(() => {
|
||||
if (!isEmpty(infoFilter.group)) {
|
||||
const val = serverInfo.value[infoFilter.group]
|
||||
if (!isEmpty(val)) {
|
||||
return map(val, (v, k) => ({
|
||||
key: k,
|
||||
value: v,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return flatMap(serverInfo.value, (value, key) => {
|
||||
return map(value, (v, k) => ({
|
||||
group: key,
|
||||
key: k,
|
||||
value: v,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const onFilterGroup = (group) => {
|
||||
if (group === infoFilter.group) {
|
||||
infoFilter.group = ''
|
||||
} else {
|
||||
infoFilter.group = group
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => prefStore.currentLanguage,
|
||||
() => {
|
||||
// force update labels of charts
|
||||
cmdRate.value.datasets[0].label = i18n.t('status.act_cmd')
|
||||
cmdRate.value = generateData(cmdRate.value)
|
||||
connectedClients.value.datasets[0].label = i18n.t('status.connected_clients')
|
||||
connectedClients.value = generateData(connectedClients.value)
|
||||
memoryUsage.value.datasets[0].label = i18n.t('status.memory_used')
|
||||
memoryUsage.value = generateData(memoryUsage.value)
|
||||
networkRate.value.datasets[0].label = i18n.t('status.act_network_input')
|
||||
networkRate.value.datasets[1].label = i18n.t('status.act_network_output')
|
||||
networkRate.value = generateData(networkRate.value)
|
||||
},
|
||||
)
|
||||
|
||||
const chartBGColor = [
|
||||
'rgba(255, 99, 132, 0.2)',
|
||||
'rgba(255, 159, 64, 0.2)',
|
||||
'rgba(153, 102, 255, 0.2)',
|
||||
'rgba(75, 192, 192, 0.2)',
|
||||
'rgba(54, 162, 235, 0.2)',
|
||||
]
|
||||
|
||||
const chartBorderColor = [
|
||||
'rgb(255, 99, 132)',
|
||||
'rgb(255, 159, 64)',
|
||||
'rgb(153, 102, 255)',
|
||||
'rgb(75, 192, 192)',
|
||||
'rgb(54, 162, 235)',
|
||||
]
|
||||
|
||||
const cmdRate = shallowRef({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.t('status.act_cmd'),
|
||||
data: [],
|
||||
fill: true,
|
||||
backgroundColor: chartBGColor[0],
|
||||
borderColor: chartBorderColor[0],
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const connectedClients = shallowRef({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.t('status.connected_clients'),
|
||||
data: [],
|
||||
fill: true,
|
||||
backgroundColor: chartBGColor[1],
|
||||
borderColor: chartBorderColor[1],
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const memoryUsage = shallowRef({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.t('status.memory_used'),
|
||||
data: [],
|
||||
fill: true,
|
||||
backgroundColor: chartBGColor[2],
|
||||
borderColor: chartBorderColor[2],
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const networkRate = shallowRef({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.t('status.act_network_input'),
|
||||
data: [],
|
||||
fill: true,
|
||||
backgroundColor: chartBGColor[3],
|
||||
borderColor: chartBorderColor[3],
|
||||
tension: 0.4,
|
||||
},
|
||||
{
|
||||
label: i18n.t('status.act_network_output'),
|
||||
data: [],
|
||||
fill: true,
|
||||
backgroundColor: chartBGColor[4],
|
||||
borderColor: chartBorderColor[4],
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chartOption = computed(() => {
|
||||
return {
|
||||
animation: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
events: [],
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: themeVars.value.borderColor,
|
||||
},
|
||||
ticks: {
|
||||
color: themeVars.value.textColor3,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
stepSize: 1024,
|
||||
suggestedMin: 0,
|
||||
grid: {
|
||||
color: themeVars.value.borderColor,
|
||||
},
|
||||
ticks: {
|
||||
color: themeVars.value.textColor3,
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: themeVars.value.textColor2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const byteChartOption = computed(() => {
|
||||
return {
|
||||
animation: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
events: [],
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: themeVars.value.borderColor,
|
||||
},
|
||||
ticks: {
|
||||
color: themeVars.value.textColor3,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
stepSize: 1024,
|
||||
suggestedMin: 0,
|
||||
grid: {
|
||||
color: themeVars.value.borderColor,
|
||||
},
|
||||
ticks: {
|
||||
color: themeVars.value.textColor3,
|
||||
precision: 0,
|
||||
// format display y axios tag
|
||||
callback: function (value, index, values) {
|
||||
return formatBytes(value, 1)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: themeVars.value.textColor2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const clientInfo = reactive({
|
||||
loading: false,
|
||||
content: [],
|
||||
})
|
||||
const onShowClients = async (show) => {
|
||||
if (show) {
|
||||
try {
|
||||
clientInfo.loading = true
|
||||
clientInfo.content = await browserStore.getClientList(props.server)
|
||||
} finally {
|
||||
clientInfo.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clientTableColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
title: () => {
|
||||
return h(NSpace, { wrap: false, wrapItem: false, justify: 'center' }, () => [
|
||||
h('span', { style: { fontWeight: '550', fontSize: '15px' } }, i18n.t('status.client.title')),
|
||||
h(IconButton, {
|
||||
icon: Refresh,
|
||||
size: 16,
|
||||
onClick: () => onShowClients(true),
|
||||
}),
|
||||
])
|
||||
},
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
children: [
|
||||
{
|
||||
key: 'no',
|
||||
title: '#',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row, index) => {
|
||||
return index + 1
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'addr',
|
||||
title: () => i18n.t('status.client.addr'),
|
||||
sorter: 'default',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
},
|
||||
{
|
||||
key: 'db',
|
||||
title: () => i18n.t('status.client.db'),
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
},
|
||||
{
|
||||
key: 'age',
|
||||
title: () => i18n.t('status.client.age'),
|
||||
sorter: (row1, row2) => row1.age - row2.age,
|
||||
defaultSortOrder: 'descend',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: ({ age }, index) => {
|
||||
return toHumanReadable(age)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'idle',
|
||||
title: () => i18n.t('status.client.idle'),
|
||||
sorter: (row1, row2) => row1.age - row2.age,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: ({ idle }, index) => {
|
||||
return toHumanReadable(idle)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-scrollbar ref="scrollRef">
|
||||
<n-back-top :listen-to="scrollRef" />
|
||||
<n-space :size="5" :wrap-item="false" style="padding: 5px" vertical>
|
||||
<n-card embedded>
|
||||
<template #header>
|
||||
<n-space :wrap-item="false" align="center" inline size="small">
|
||||
{{ props.server }}
|
||||
<n-tooltip v-if="redisVersion">
|
||||
Redis Version
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="primary">v{{ redisVersion }}</n-tag>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="redisMode">
|
||||
Mode
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="primary">{{ redisMode }}</n-tag>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="redisMode">
|
||||
Role
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="primary">{{ role }}</n-tag>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center" inline>
|
||||
{{ $t('status.auto_refresh') }}
|
||||
<n-switch v-model:value="autoRefresh" :loading="autoLoading" />
|
||||
<n-tooltip>
|
||||
{{ $t('status.refresh') }}
|
||||
<template #trigger>
|
||||
<n-button
|
||||
:loading="autoLoading"
|
||||
circle
|
||||
size="small"
|
||||
tertiary
|
||||
@click="refreshInfo(true)">
|
||||
<template #icon>
|
||||
<n-icon :component="Refresh" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-spin :show="loading">
|
||||
<n-grid style="min-width: 500px" x-gap="5">
|
||||
<n-gi :span="6">
|
||||
<n-statistic :label="$t('status.uptime')" :value="uptime[0]">
|
||||
<template #suffix>{{ $t(uptime[1]) }}</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-statistic
|
||||
:label="$t('status.connected_clients')"
|
||||
:value="get(serverInfo, 'Clients.connected_clients', 0)" />
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-statistic :value="totalKeys">
|
||||
<template #label>
|
||||
{{ $t('status.total_keys') }}
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-statistic :label="$t('status.memory_used')" :value="usedMemory[0]">
|
||||
<template #suffix>{{ usedMemory[1] }}</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
<n-card :title="$t('status.all_info')" embedded>
|
||||
<template #header-extra>
|
||||
<n-input v-model:value="infoFilter" clearable placeholder="">
|
||||
<template #prefix>
|
||||
<icon-button :icon="Filter" size="18" />
|
||||
<n-space :size="5" :wrap-item="false" style="padding: 5px; box-sizing: border-box; height: 100%" vertical>
|
||||
<n-card embedded>
|
||||
<template #header>
|
||||
<n-space :wrap-item="false" align="center" inline size="small">
|
||||
{{ props.server }}
|
||||
<n-tooltip v-if="redisVersion">
|
||||
Redis Version
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="primary">v{{ redisVersion }}</n-tag>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="redisMode">
|
||||
Mode
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="primary">{{ redisMode }}</n-tag>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="role">
|
||||
Role
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="primary">{{ role }}</n-tag>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-popover keep-alive-on-hover placement="bottom-end" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
:loading="pageState.loading"
|
||||
:type="isLoading ? 'primary' : 'default'"
|
||||
circle
|
||||
size="small"
|
||||
tertiary
|
||||
@click="refreshInfo(true)">
|
||||
<template #icon>
|
||||
<n-icon :size="props.size">
|
||||
<refresh
|
||||
:class="{
|
||||
'auto-rotate': pageState.autoRefresh || isLoading,
|
||||
}"
|
||||
:color="pageState.autoRefresh ? themeVars.primaryColor : undefined"
|
||||
:stroke-width="pageState.autoRefresh ? 6 : 3" />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<auto-refresh-form
|
||||
v-model:interval="pageState.refreshInterval"
|
||||
v-model:on="pageState.autoRefresh"
|
||||
:default-value="5"
|
||||
:loading="pageState.autoLoading"
|
||||
@toggle="onToggleRefresh" />
|
||||
</n-popover>
|
||||
</template>
|
||||
<n-grid style="min-width: 500px" x-gap="5">
|
||||
<n-gi :span="6">
|
||||
<n-statistic :label="$t('status.uptime')" :value="uptime.value">
|
||||
<template #suffix>{{ $t(uptime.unit) }}</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-statistic
|
||||
:label="$t('status.connected_clients')"
|
||||
:value="get(serverInfo, 'Clients.connected_clients', '0')">
|
||||
<template #suffix>
|
||||
<n-tooltip
|
||||
:content-style="{ backgroundColor: themeVars.tableColor }"
|
||||
trigger="click"
|
||||
width="70vw"
|
||||
@update-show="onShowClients">
|
||||
<template #trigger>
|
||||
<n-button :bordered="false" size="small">↘</n-button>
|
||||
</template>
|
||||
<n-data-table
|
||||
:columns="clientTableColumns"
|
||||
:data="clientInfo.content"
|
||||
:loading="clientInfo.loading"
|
||||
:single-column="false"
|
||||
:single-line="false"
|
||||
max-height="50vh"
|
||||
size="small"
|
||||
striped />
|
||||
</n-tooltip>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-statistic :value="totalKeys">
|
||||
<template #label>
|
||||
{{ $t('status.total_keys') }}
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-statistic :label="$t('status.memory_used')" :value="usedMemory[0]">
|
||||
<template #suffix>{{ usedMemory[1] }}</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
<n-card class="flex-item-expand" content-style="padding: 0; height: 100%;" embedded style="overflow: hidden">
|
||||
<n-tabs
|
||||
v-model:value="tabVal"
|
||||
:tabs-padding="20"
|
||||
pane-style="padding: 10px; box-sizing: border-box; display: flex; flex-direction: column; flex-grow: 1;"
|
||||
size="large"
|
||||
style="height: 100%; overflow: hidden"
|
||||
type="line">
|
||||
<template #suffix>
|
||||
<div v-if="tabVal === 'info'" style="padding-right: 10px">
|
||||
<n-input v-model:value="infoFilter.keyword" clearable placeholder="">
|
||||
<template #prefix>
|
||||
<icon-button :icon="Filter" size="18" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin :show="loading">
|
||||
<n-tabs default-value="CPU" placement="left" type="line">
|
||||
<n-tab-pane v-for="(v, k) in serverInfo" :key="k" :disabled="isEmpty(v)" :name="k">
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{
|
||||
title: $t('common.key'),
|
||||
key: 'key',
|
||||
defaultSortOrder: 'ascend',
|
||||
sorter: 'default',
|
||||
minWidth: 100,
|
||||
filterOptionValue: infoFilter,
|
||||
filter(value, row) {
|
||||
return !!~row.key.indexOf(value.toString())
|
||||
},
|
||||
|
||||
<!-- activity tab pane -->
|
||||
<n-tab-pane
|
||||
:tab="$t('status.activity_status')"
|
||||
class="line-chart"
|
||||
display-directive="show:lazy"
|
||||
name="activity">
|
||||
<div class="line-chart">
|
||||
<div class="line-chart-item">
|
||||
<Line :data="cmdRate" :options="chartOption" />
|
||||
</div>
|
||||
<div class="line-chart-item">
|
||||
<Line :data="connectedClients" :options="chartOption" />
|
||||
</div>
|
||||
<div class="line-chart-item">
|
||||
<Line :data="memoryUsage" :options="byteChartOption" />
|
||||
</div>
|
||||
<div class="line-chart-item">
|
||||
<Line :data="networkRate" :options="byteChartOption" />
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- info tab pane -->
|
||||
<n-tab-pane :tab="$t('status.server_info')" name="info">
|
||||
<n-space :wrap="false" :wrap-item="false" class="flex-item-expand">
|
||||
<n-space align="end" item-style="padding: 0 5px;" vertical>
|
||||
<n-button
|
||||
v-for="(v, k) in serverInfo"
|
||||
:key="k"
|
||||
:disabled="isEmpty(v)"
|
||||
:focusable="false"
|
||||
:type="infoFilter.group === k ? 'primary' : 'default'"
|
||||
secondary
|
||||
size="small"
|
||||
@click="onFilterGroup(k)">
|
||||
<span style="min-width: 80px">{{ k }}</span>
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{
|
||||
title: $t('common.key'),
|
||||
key: 'key',
|
||||
defaultSortOrder: 'ascend',
|
||||
minWidth: 80,
|
||||
titleAlign: 'center',
|
||||
filterOptionValue: infoFilter.keyword,
|
||||
filter(value, row) {
|
||||
return !!~row.key.indexOf(value.toString())
|
||||
},
|
||||
{ title: $t('common.value'), key: 'value' },
|
||||
]"
|
||||
:data="map(v, (value, key) => ({ value, key }))" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-scrollbar>
|
||||
},
|
||||
{ title: $t('common.value'), titleAlign: 'center', key: 'value' },
|
||||
]"
|
||||
:data="info"
|
||||
:loading="pageState.loading"
|
||||
:single-line="false"
|
||||
class="flex-item-expand"
|
||||
flex-height
|
||||
striped />
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/content';
|
||||
|
||||
.line-chart {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
<script setup>
|
||||
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import Refresh from '@/components/icons/Refresh.vue'
|
||||
import { debounce, isEmpty, map, size, split } from 'lodash'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { NIcon, useThemeVars } from 'naive-ui'
|
||||
import dayjs from 'dayjs'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import { timeout } from '@/utils/promise.js'
|
||||
import AutoRefreshForm from '@/components/common/AutoRefreshForm.vue'
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
|
@ -15,10 +17,11 @@ const props = defineProps({
|
|||
server: {
|
||||
type: String,
|
||||
},
|
||||
db: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const autoRefresh = reactive({
|
||||
on: false,
|
||||
interval: 5,
|
||||
})
|
||||
|
||||
const data = reactive({
|
||||
|
@ -26,7 +29,6 @@ const data = reactive({
|
|||
sortOrder: 'descend',
|
||||
listLimit: 20,
|
||||
loading: false,
|
||||
autoLoading: false,
|
||||
client: '',
|
||||
keyword: '',
|
||||
})
|
||||
|
@ -35,7 +37,7 @@ const tableRef = ref(null)
|
|||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: i18n.t('slog.exec_time'),
|
||||
title: () => i18n.t('slog.exec_time'),
|
||||
key: 'timestamp',
|
||||
sortOrder: data.sortOrder,
|
||||
sorter: 'default',
|
||||
|
@ -47,7 +49,7 @@ const columns = computed(() => [
|
|||
},
|
||||
},
|
||||
{
|
||||
title: i18n.t('slog.client'),
|
||||
title: () => i18n.t('slog.client'),
|
||||
key: 'client',
|
||||
filterOptionValue: data.client,
|
||||
resizable: true,
|
||||
|
@ -58,7 +60,13 @@ const columns = computed(() => [
|
|||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
},
|
||||
render: ({ client, addr }, index) => {
|
||||
let content = ''
|
||||
|
@ -75,7 +83,7 @@ const columns = computed(() => [
|
|||
},
|
||||
},
|
||||
{
|
||||
title: i18n.t('slog.cmd'),
|
||||
title: () => i18n.t('slog.cmd'),
|
||||
key: 'cmd',
|
||||
titleAlign: 'center',
|
||||
filterOptionValue: data.keyword,
|
||||
|
@ -89,14 +97,14 @@ const columns = computed(() => [
|
|||
return h(
|
||||
'div',
|
||||
null,
|
||||
map(cmdList, (c) => h('div', null, c)),
|
||||
map(cmdList, (c) => h('div', { class: 'cmd-line' }, c)),
|
||||
)
|
||||
}
|
||||
return cmd
|
||||
return h('div', { class: 'cmd-line' }, cmd)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.t('slog.cost_time'),
|
||||
title: () => i18n.t('slog.cost_time'),
|
||||
key: 'cost',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
|
@ -115,30 +123,49 @@ const columns = computed(() => [
|
|||
const _loadSlowLog = () => {
|
||||
data.loading = true
|
||||
browserStore
|
||||
.getSlowLog(props.server, props.db, data.listLimit)
|
||||
.getSlowLog(props.server, data.listLimit)
|
||||
.then((list) => {
|
||||
data.list = list || []
|
||||
})
|
||||
.finally(() => {
|
||||
.finally(async () => {
|
||||
data.loading = false
|
||||
tableRef.value?.scrollTo({ top: data.sortOrder === 'ascend' ? 999999 : 0 })
|
||||
await nextTick()
|
||||
tableRef.value?.scrollTo({ position: data.sortOrder === 'ascend' ? 'bottom' : 'top' })
|
||||
})
|
||||
}
|
||||
const loadSlowLog = debounce(_loadSlowLog, 1000, { leading: true, trailing: true })
|
||||
|
||||
let intervalID
|
||||
onMounted(() => {
|
||||
loadSlowLog()
|
||||
intervalID = setInterval(() => {
|
||||
if (data.autoLoading === true) {
|
||||
loadSlowLog()
|
||||
const startAutoRefresh = async () => {
|
||||
let lastExec = Date.now()
|
||||
do {
|
||||
if (!autoRefresh.on) {
|
||||
break
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
await timeout(100)
|
||||
if (data.loading || Date.now() - lastExec < autoRefresh.interval * 1000) {
|
||||
continue
|
||||
}
|
||||
lastExec = Date.now()
|
||||
loadSlowLog()
|
||||
} while (true)
|
||||
stopAutoRefresh()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalID)
|
||||
})
|
||||
const stopAutoRefresh = () => {
|
||||
autoRefresh.on = false
|
||||
}
|
||||
|
||||
onMounted(() => loadSlowLog())
|
||||
|
||||
onUnmounted(() => stopAutoRefresh())
|
||||
|
||||
const onToggleRefresh = (on) => {
|
||||
if (on) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const onListLimitChanged = (limit) => {
|
||||
loadSlowLog()
|
||||
|
@ -146,53 +173,54 @@ const onListLimitChanged = (limit) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
:bordered="false"
|
||||
:theme-overrides="{ borderRadius: '0px' }"
|
||||
:title="$t('slog.title')"
|
||||
class="content-container flex-box-v"
|
||||
content-style="display: flex;flex-direction: column; overflow: hidden; backgroundColor: gray">
|
||||
<div class="content-log content-container content-value fill-height flex-box-v">
|
||||
<n-form :disabled="data.loading" class="flex-item" inline>
|
||||
<n-form-item :label="$t('slog.limit')">
|
||||
<n-input-number
|
||||
v-model:value="data.listLimit"
|
||||
:max="9999"
|
||||
:min="1"
|
||||
style="width: 120px"
|
||||
@update:value="onListLimitChanged" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('slog.auto_refresh')">
|
||||
<n-switch v-model:value="data.autoLoading" :loading="data.loading" />
|
||||
</n-form-item>
|
||||
<n-form-item label=" ">
|
||||
<n-tooltip>
|
||||
{{ $t('slog.refresh') }}
|
||||
<template #trigger>
|
||||
<n-button :loading="data.loading" circle size="small" tertiary @click="_loadSlowLog">
|
||||
<template #icon>
|
||||
<n-icon :component="Refresh" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-tooltip>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('slog.filter')">
|
||||
<n-input v-model:value="data.keyword" clearable placeholder="" />
|
||||
</n-form-item>
|
||||
<n-form-item label=" ">
|
||||
<n-popover :delay="500" keep-alive-on-hover placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button :loading="data.loading" circle size="small" tertiary @click="_loadSlowLog">
|
||||
<template #icon>
|
||||
<n-icon :size="props.size">
|
||||
<refresh
|
||||
:class="{ 'auto-rotate': autoRefresh.on }"
|
||||
:color="autoRefresh.on ? themeVars.primaryColor : undefined"
|
||||
:stroke-width="autoRefresh.on ? 6 : 3" />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<auto-refresh-form
|
||||
v-model:interval="autoRefresh.interval"
|
||||
v-model:on="autoRefresh.on"
|
||||
:default-value="5"
|
||||
:loading="data.loading"
|
||||
@toggle="onToggleRefresh" />
|
||||
</n-popover>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="content-value fill-height flex-box-h">
|
||||
<n-data-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data="data.list"
|
||||
:loading="data.loading"
|
||||
class="flex-item-expand"
|
||||
flex-height
|
||||
virtual-scroll
|
||||
@update:sorter="({ order }) => (data.sortOrder = order)" />
|
||||
</div>
|
||||
</n-card>
|
||||
<n-data-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data="data.list"
|
||||
:loading="data.loading"
|
||||
class="flex-item-expand"
|
||||
flex-height
|
||||
striped
|
||||
@update:sorter="({ order }) => (data.sortOrder = order)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/content';
|
||||
@use '@/styles/content';
|
||||
</style>
|
||||
|
|
|
@ -9,9 +9,12 @@ import RedisTypeTag from '@/components/common/RedisTypeTag.vue'
|
|||
import { useI18n } from 'vue-i18n'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
import Copy from '@/components/icons/Copy.vue'
|
||||
import { ClipboardSetText } from 'wailsjs/runtime/runtime.js'
|
||||
import { computed } from 'vue'
|
||||
import { padStart } from 'lodash'
|
||||
import { computed, onMounted, onUnmounted, reactive, watch } from 'vue'
|
||||
import { NIcon, useThemeVars } from 'naive-ui'
|
||||
import { timeout } from '@/utils/promise.js'
|
||||
import AutoRefreshForm from '@/components/common/AutoRefreshForm.vue'
|
||||
import { toHumanReadable } from '@/utils/date.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
|
||||
const props = defineProps({
|
||||
server: String,
|
||||
|
@ -37,6 +40,18 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(['reload', 'rename', 'delete'])
|
||||
|
||||
const autoRefresh = reactive({
|
||||
on: false,
|
||||
interval: 2,
|
||||
})
|
||||
|
||||
const ttl = reactive({
|
||||
value: 0,
|
||||
expire: 0,
|
||||
intervalID: 0,
|
||||
})
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
const dialogStore = useDialog()
|
||||
const i18n = useI18n()
|
||||
|
||||
|
@ -45,32 +60,96 @@ const binaryKey = computed(() => {
|
|||
})
|
||||
|
||||
const ttlString = computed(() => {
|
||||
let s = ''
|
||||
if (props.ttl > 0) {
|
||||
const hours = Math.floor(props.ttl / 3600)
|
||||
s += padStart(hours + ':', 3, '0')
|
||||
const minutes = Math.floor((props.ttl % 3600) / 60)
|
||||
s += padStart(minutes + ':', 3, '0')
|
||||
const seconds = Math.floor(props.ttl % 60)
|
||||
s += padStart(seconds + '', 2, '0')
|
||||
} else if (props.ttl < 0) {
|
||||
s = i18n.t('interface.forever')
|
||||
if (ttl.value > 0) {
|
||||
return toHumanReadable(ttl.value)
|
||||
} else if (ttl.value < 0) {
|
||||
return i18n.t('interface.forever')
|
||||
} else {
|
||||
s = '00:00:00'
|
||||
return '00:00:00'
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
const startAutoRefresh = async () => {
|
||||
let lastExec = Date.now()
|
||||
do {
|
||||
if (!autoRefresh.on) {
|
||||
break
|
||||
}
|
||||
await timeout(100)
|
||||
if (props.loading || Date.now() - lastExec < autoRefresh.interval * 1000) {
|
||||
continue
|
||||
}
|
||||
lastExec = Date.now()
|
||||
emit('reload')
|
||||
} while (true)
|
||||
stopAutoRefresh()
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
autoRefresh.on = false
|
||||
}
|
||||
|
||||
const syncTTL = (seconds) => {
|
||||
ttl.value = seconds
|
||||
if (seconds >= 0) {
|
||||
ttl.expire = Math.floor(Date.now() / 1000 + seconds)
|
||||
} else {
|
||||
ttl.expire = 0
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.keyPath,
|
||||
() => {
|
||||
stopAutoRefresh()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.ttl,
|
||||
(seconds) => syncTTL(seconds),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncTTL(props.ttl)
|
||||
ttl.intervalID = setInterval(() => {
|
||||
if (ttl.expire > 0) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
ttl.value = Math.max(0, ttl.expire - nowSeconds)
|
||||
} else {
|
||||
ttl.value = -1
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
if (ttl.intervalID > 0) {
|
||||
clearInterval(ttl.intervalID)
|
||||
ttl.intervalID = 0
|
||||
}
|
||||
})
|
||||
|
||||
const onToggleRefresh = (on) => {
|
||||
if (on) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const onCopyKey = () => {
|
||||
ClipboardSetText(props.keyPath)
|
||||
.then((succ) => {
|
||||
if (succ) {
|
||||
$message.success(i18n.t('dialogue.copy_succ'))
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
$message.error(e.message)
|
||||
})
|
||||
copy(props.keyPath)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
}
|
||||
|
||||
const onTTL = () => {
|
||||
dialogStore.openTTLDialog({
|
||||
server: props.server,
|
||||
db: props.db,
|
||||
key: binaryKey.value ? props.keyCode : props.keyPath,
|
||||
ttl: ttl.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -78,14 +157,30 @@ const onCopyKey = () => {
|
|||
<div class="content-toolbar flex-box-h">
|
||||
<n-input-group>
|
||||
<redis-type-tag :binary-key="binaryKey" :type="props.keyType" size="large" />
|
||||
<n-input v-model:value="props.keyPath" readonly>
|
||||
<n-input v-model:value="props.keyPath" :title="props.keyPath" readonly @dblclick="onCopyKey">
|
||||
<template #suffix>
|
||||
<icon-button
|
||||
:icon="Refresh"
|
||||
:loading="props.loading"
|
||||
size="18"
|
||||
t-tooltip="interface.reload"
|
||||
@click="emit('reload')" />
|
||||
<n-popover :delay="500" keep-alive-on-hover placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<icon-button
|
||||
:loading="props.loading"
|
||||
size="18"
|
||||
@click="emit('reload')"
|
||||
@dblclick.stop="() => {}">
|
||||
<n-icon :size="props.size">
|
||||
<refresh
|
||||
:class="{ 'auto-rotate': autoRefresh.on }"
|
||||
:color="autoRefresh.on ? themeVars.primaryColor : undefined"
|
||||
:stroke-width="autoRefresh.on ? 6 : 3" />
|
||||
</n-icon>
|
||||
</icon-button>
|
||||
</template>
|
||||
<auto-refresh-form
|
||||
v-model:interval="autoRefresh.interval"
|
||||
v-model:on="autoRefresh.on"
|
||||
:default-value="2"
|
||||
:loading="props.loading"
|
||||
@toggle="onToggleRefresh" />
|
||||
</n-popover>
|
||||
</template>
|
||||
</n-input>
|
||||
<icon-button :icon="Copy" border size="18" t-tooltip="interface.copy_key" @click="onCopyKey" />
|
||||
|
@ -93,16 +188,22 @@ const onCopyKey = () => {
|
|||
<n-button-group>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button :focusable="false" @click="dialogStore.openTTLDialog(props.ttl)">
|
||||
<n-button :focusable="false" @click="onTTL">
|
||||
<template #icon>
|
||||
<n-icon :component="Timer" size="18" />
|
||||
</template>
|
||||
{{ ttlString }}
|
||||
<span style="font-variant-numeric: tabular-nums">{{ ttlString }}</span>
|
||||
</n-button>
|
||||
</template>
|
||||
TTL{{ `${ttl > 0 ? ': ' + ttl + $t('common.second') : ''}` }}
|
||||
</n-tooltip>
|
||||
<icon-button :icon="Edit" border size="18" t-tooltip="interface.rename_key" @click="emit('rename')" />
|
||||
<icon-button
|
||||
:disabled="binaryKey"
|
||||
:icon="Edit"
|
||||
:t-tooltip="binaryKey ? 'dialogue.rename_binary_key_fail' : 'interface.rename_key'"
|
||||
border
|
||||
size="18"
|
||||
@click="emit('rename')" />
|
||||
</n-button-group>
|
||||
<n-tooltip :show-arrow="false">
|
||||
<template #trigger>
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
<script setup>
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from './ContentToolbar.vue'
|
||||
import AddLink from '@/components/icons/AddLink.vue'
|
||||
import { NButton, NCode, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { types, types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import EditableTableColumn from '@/components/common/EditableTableColumn.vue'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import { isEmpty, size } from 'lodash'
|
||||
import bytes from 'bytes'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import LoadList from '@/components/icons/LoadList.vue'
|
||||
|
@ -17,8 +15,14 @@ import IconButton from '@/components/common/IconButton.vue'
|
|||
import ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vue'
|
||||
import Edit from '@/components/icons/Edit.vue'
|
||||
import FormatSelector from '@/components/content_value/FormatSelector.vue'
|
||||
import { decodeRedisKey } from '@/utils/key_convert.js'
|
||||
import { decodeRedisKey, nativeRedisKey } from '@/utils/key_convert.js'
|
||||
import ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
import SwitchButton from '@/components/common/SwitchButton.vue'
|
||||
import AlignLeft from '@/components/icons/AlignLeft.vue'
|
||||
import AlignCenter from '@/components/icons/AlignCenter.vue'
|
||||
import { TextAlignType } from '@/consts/text_align_type.js'
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
@ -36,24 +40,19 @@ const props = defineProps({
|
|||
default: -1,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
type: [String, Array],
|
||||
default: () => [],
|
||||
},
|
||||
size: Number,
|
||||
length: Number,
|
||||
format: {
|
||||
type: String,
|
||||
default: formatTypes.RAW,
|
||||
},
|
||||
decode: {
|
||||
type: String,
|
||||
default: decodeTypes.NONE,
|
||||
},
|
||||
format: String,
|
||||
decode: String,
|
||||
end: Boolean,
|
||||
loading: Boolean,
|
||||
textAlign: Number,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'rename', 'delete', 'match'])
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -83,37 +82,54 @@ const tableRef = ref(null)
|
|||
const fieldFilterOption = ref(null)
|
||||
const fieldColumn = computed(() => ({
|
||||
key: 'key',
|
||||
title: i18n.t('common.field'),
|
||||
align: 'center',
|
||||
title: () => i18n.t('common.field'),
|
||||
align: props.textAlign !== TextAlignType.Left ? 'center' : 'left',
|
||||
titleAlign: 'center',
|
||||
resizable: true,
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
lineClamp: 1,
|
||||
},
|
||||
filterOptionValue: fieldFilterOption.value,
|
||||
className: inEdit.value ? 'clickable' : '',
|
||||
className: inEdit.value ? 'clickable wordline' : 'wordline',
|
||||
filter: (value, row) => {
|
||||
return !!~row.k.indexOf(value.toString())
|
||||
},
|
||||
render: (row) => {
|
||||
if (row.rm === true) {
|
||||
return h('s', {}, decodeRedisKey(row.k))
|
||||
}
|
||||
return decodeRedisKey(row.k)
|
||||
},
|
||||
}))
|
||||
|
||||
const displayCode = computed(() => {
|
||||
return props.format === formatTypes.JSON
|
||||
const isCode = computed(() => {
|
||||
return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON
|
||||
})
|
||||
// const valueFilterOption = ref(null)
|
||||
const valueColumn = computed(() => ({
|
||||
key: 'value',
|
||||
title: i18n.t('common.value'),
|
||||
align: displayCode.value ? 'left' : 'center',
|
||||
title: () => i18n.t('common.value'),
|
||||
align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',
|
||||
titleAlign: 'center',
|
||||
resizable: true,
|
||||
ellipsis: displayCode.value
|
||||
ellipsis: isCode.value
|
||||
? false
|
||||
: {
|
||||
tooltip: true,
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
lineClamp: 1,
|
||||
},
|
||||
// filterOptionValue: valueFilterOption.value,
|
||||
className: inEdit.value ? 'clickable' : '',
|
||||
|
@ -124,10 +140,14 @@ const valueColumn = computed(() => ({
|
|||
// return !!~row.v.indexOf(value.toString())
|
||||
// },
|
||||
render: (row) => {
|
||||
if (displayCode.value) {
|
||||
return h(NCode, { language: 'json', wordWrap: true, code: row.dv || row.v })
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
if (isCode.value) {
|
||||
return h('pre', { class: 'pre-wrap' }, val)
|
||||
}
|
||||
return row.dv || row.v
|
||||
if (row.rm === true) {
|
||||
return h('s', {}, val)
|
||||
}
|
||||
return val
|
||||
},
|
||||
}))
|
||||
|
||||
|
@ -135,6 +155,8 @@ const startEdit = async (no, key, value) => {
|
|||
currentEditRow.no = no
|
||||
currentEditRow.key = key
|
||||
currentEditRow.value = value
|
||||
currentEditRow.decode = props.decode
|
||||
currentEditRow.format = props.format
|
||||
}
|
||||
|
||||
const saveEdit = async (field, value, decode, format) => {
|
||||
|
@ -162,7 +184,8 @@ const saveEdit = async (field, value, decode, format) => {
|
|||
index: [currentEditRow.no - 1],
|
||||
})
|
||||
if (success) {
|
||||
$message.success(i18n.t('dialogue.save_value_succ'))
|
||||
currentEditRow.value = value
|
||||
$message.success(i18n.t('interface.save_value_succ'))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -175,14 +198,17 @@ const resetEdit = () => {
|
|||
currentEditRow.no = 0
|
||||
currentEditRow.key = ''
|
||||
currentEditRow.value = null
|
||||
currentEditRow.format = formatTypes.RAW
|
||||
currentEditRow.decode = decodeTypes.NONE
|
||||
// if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {
|
||||
// nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))
|
||||
// }
|
||||
// currentEditRow.format = formatTypes.RAW
|
||||
// currentEditRow.decode = decodeTypes.NONE
|
||||
}
|
||||
|
||||
const actionColumn = {
|
||||
key: 'action',
|
||||
title: i18n.t('interface.action'),
|
||||
width: 100,
|
||||
title: () => i18n.t('interface.action'),
|
||||
width: 120,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
fixed: 'right',
|
||||
|
@ -190,18 +216,42 @@ const actionColumn = {
|
|||
return h(EditableTableColumn, {
|
||||
editing: false,
|
||||
bindKey: row.k,
|
||||
canRefresh: true,
|
||||
onRefresh: async () => {
|
||||
const { updated, success, msg } = await browserStore.getHashField({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
field: row.k,
|
||||
decode: props.decode,
|
||||
format: props.format,
|
||||
})
|
||||
if (success) {
|
||||
delete props.value[index]['rm']
|
||||
$message.success(i18n.t('dialogue.reload_succ'))
|
||||
} else {
|
||||
// update fail, the key may have been deleted
|
||||
$message.error(msg)
|
||||
props.value[index]['rm'] = true
|
||||
}
|
||||
},
|
||||
onCopy: async () => {
|
||||
copy(row.v)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
},
|
||||
onEdit: () => startEdit(index + 1, row.k, row.v),
|
||||
onDelete: async () => {
|
||||
try {
|
||||
const { removed, success, msg } = await browserStore.removeHashField(
|
||||
props.name,
|
||||
props.db,
|
||||
keyName.value,
|
||||
row.k,
|
||||
)
|
||||
const { removed, success, msg } = await browserStore.removeHashField({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
field: row.k,
|
||||
reload: false,
|
||||
})
|
||||
if (success) {
|
||||
props.value.splice(index, 1)
|
||||
$message.success(i18n.t('dialogue.delete_key_succ', { key: row.k }))
|
||||
$message.success(i18n.t('dialogue.delete.success', { key: row.k }))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -268,6 +318,15 @@ const entries = computed(() => {
|
|||
return `${len} / ${Math.max(len, props.length)}`
|
||||
})
|
||||
|
||||
const loadProgress = computed(() => {
|
||||
const len = size(props.value)
|
||||
return (len * 100) / Math.max(len, props.length)
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
const onAddRow = () => {
|
||||
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.HASH)
|
||||
}
|
||||
|
@ -300,26 +359,24 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<content-toolbar
|
||||
:db="props.db"
|
||||
:key-code="props.keyCode"
|
||||
:key-path="props.keyPath"
|
||||
:key-type="keyType"
|
||||
:loading="props.loading"
|
||||
:server="props.name"
|
||||
:ttl="ttl"
|
||||
class="value-item-part"
|
||||
@delete="emit('delete')"
|
||||
@reload="emit('reload')"
|
||||
@rename="emit('rename')" />
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-box-h">
|
||||
<div class="flex-box-h" style="max-width: 50%">
|
||||
<content-search-input
|
||||
ref="searchInputRef"
|
||||
@filter-changed="onFilterInput"
|
||||
@match-changed="onMatchInput" />
|
||||
</div>
|
||||
<div class="flex-item-expand"></div>
|
||||
<switch-button
|
||||
:icons="[AlignCenter, AlignLeft]"
|
||||
:stroke-width="3.5"
|
||||
:t-tooltips="['interface.text_align_center', 'interface.text_align_left']"
|
||||
:value="props.textAlign"
|
||||
size="medium"
|
||||
unselect-stroke-width="3"
|
||||
@update:value="(val) => emit('update:textAlign', val)" />
|
||||
<n-divider vertical />
|
||||
<n-button-group>
|
||||
<icon-button
|
||||
:disabled="props.end || props.loading"
|
||||
|
@ -343,6 +400,16 @@ defineExpose({
|
|||
{{ $t('interface.add_row') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- loaded progress -->
|
||||
<n-progress
|
||||
:border-radius="0"
|
||||
:color="props.end ? '#0000' : themeVars.primaryColor"
|
||||
:height="2"
|
||||
:percentage="loadProgress"
|
||||
:processing="props.loading"
|
||||
:show-indicator="false"
|
||||
status="success"
|
||||
type="line" />
|
||||
<div id="content-table" class="value-wrapper value-item-part flex-box-h flex-item-expand">
|
||||
<!-- table -->
|
||||
<n-data-table
|
||||
|
@ -370,11 +437,13 @@ defineExpose({
|
|||
class="entry-editor-container flex-item-expand"
|
||||
style="width: 100%">
|
||||
<content-entry-editor
|
||||
v-model:decode="currentEditRow.decode"
|
||||
v-model:format="currentEditRow.format"
|
||||
v-model:fullscreen="fullEdit"
|
||||
:decode="currentEditRow.decode"
|
||||
:field="currentEditRow.key"
|
||||
:field-label="$t('common.field')"
|
||||
:format="currentEditRow.format"
|
||||
:key-path="props.keyPath"
|
||||
:show="inEdit"
|
||||
:value="currentEditRow.value"
|
||||
:value-label="$t('common.value')"
|
||||
class="flex-item-expand"
|
||||
|
@ -385,8 +454,8 @@ defineExpose({
|
|||
</div>
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="!isNaN(props.length)">{{ $t('interface.entries') }}: {{ entries }}</n-text>
|
||||
<n-divider v-if="!isNaN(props.length)" vertical />
|
||||
<n-text v-if="!isNaN(props.size)">{{ $t('interface.memory_usage') }}: {{ bytes(props.size) }}</n-text>
|
||||
<n-divider v-if="showMemoryUsage" vertical />
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand"></div>
|
||||
<format-selector
|
||||
v-show="!inEdit"
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Copy from '@/components/icons/Copy.vue'
|
||||
import Save from '@/components/icons/Save.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import { isEmpty, toLower } from 'lodash'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import { decodeRedisKey } from '@/utils/key_convert.js'
|
||||
import ContentEditor from '@/components/content_value/ContentEditor.vue'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
|
||||
const props = defineProps({
|
||||
name: String,
|
||||
db: Number,
|
||||
keyPath: String,
|
||||
keyCode: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
ttl: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
value: String,
|
||||
size: Number,
|
||||
length: Number,
|
||||
loading: Boolean,
|
||||
})
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {ComputedRef<string|number[]>}
|
||||
*/
|
||||
const keyName = computed(() => {
|
||||
return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath
|
||||
})
|
||||
|
||||
const keyType = redisTypes.JSON
|
||||
|
||||
const editingContent = ref('')
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return decodeRedisKey(props.value) || ''
|
||||
})
|
||||
|
||||
const enableSave = computed(() => {
|
||||
return editingContent.value !== displayValue.value && !props.loading
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Copy value
|
||||
*/
|
||||
const onCopyValue = () => {
|
||||
copy(displayValue.value)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Save value
|
||||
*/
|
||||
const browserStore = useBrowserStore()
|
||||
const saving = ref(false)
|
||||
|
||||
const onInput = (content) => {
|
||||
editingContent.value = content
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const { success, msg } = await browserStore.setKey({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
keyType: toLower(keyType),
|
||||
value: editingContent.value,
|
||||
ttl: -1,
|
||||
format: formatTypes.JSON,
|
||||
decode: decodeTypes.NONE,
|
||||
})
|
||||
if (success) {
|
||||
$message.success(i18n.t('interface.save_value_succ'))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
} catch (e) {
|
||||
$message.error(e.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset: () => {
|
||||
editingContent.value = ''
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-item-expand"></div>
|
||||
<n-button-group>
|
||||
<n-button :disabled="saving" :focusable="false" @click="onCopyValue">
|
||||
<template #icon>
|
||||
<n-icon :component="Copy" size="18" />
|
||||
</template>
|
||||
{{ $t('interface.copy_value') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
:disabled="!enableSave"
|
||||
:loading="saving"
|
||||
:secondary="enableSave"
|
||||
:type="enableSave ? 'primary' : ''"
|
||||
@click="onSave">
|
||||
<template #icon>
|
||||
<n-icon :component="Save" size="18" />
|
||||
</template>
|
||||
{{ $t('common.save') }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
<div class="value-wrapper value-item-part flex-item-expand flex-box-v">
|
||||
<content-editor
|
||||
:content="displayValue"
|
||||
:loading="props.loading"
|
||||
:offset-key="props.keyPath"
|
||||
class="flex-item-expand"
|
||||
keep-offset
|
||||
language="json"
|
||||
style="height: 100%"
|
||||
@input="onInput"
|
||||
@reset="onInput"
|
||||
@save="onSave" />
|
||||
<n-spin v-show="props.loading" />
|
||||
</div>
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.value-wrapper {
|
||||
//overflow: hidden;
|
||||
border-top: v-bind('themeVars.borderColor') 1px solid;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.value-footer {
|
||||
border-top: v-bind('themeVars.borderColor') 1px solid;
|
||||
background-color: v-bind('themeVars.tableHeaderColor');
|
||||
}
|
||||
</style>
|
|
@ -1,14 +1,12 @@
|
|||
<script setup>
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from './ContentToolbar.vue'
|
||||
import AddLink from '@/components/icons/AddLink.vue'
|
||||
import { NButton, NCode, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { isEmpty, size } from 'lodash'
|
||||
import { types, types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import EditableTableColumn from '@/components/common/EditableTableColumn.vue'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import bytes from 'bytes'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import LoadList from '@/components/icons/LoadList.vue'
|
||||
|
@ -18,6 +16,13 @@ import ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vu
|
|||
import FormatSelector from '@/components/content_value/FormatSelector.vue'
|
||||
import Edit from '@/components/icons/Edit.vue'
|
||||
import ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
import { TextAlignType } from '@/consts/text_align_type.js'
|
||||
import AlignLeft from '@/components/icons/AlignLeft.vue'
|
||||
import AlignCenter from '@/components/icons/AlignCenter.vue'
|
||||
import SwitchButton from '@/components/common/SwitchButton.vue'
|
||||
import { nativeRedisKey } from '@/utils/key_convert.js'
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
@ -51,9 +56,10 @@ const props = defineProps({
|
|||
},
|
||||
end: Boolean,
|
||||
loading: Boolean,
|
||||
textAlign: Number,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'rename', 'delete', 'match'])
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -77,39 +83,47 @@ const inEdit = computed(() => {
|
|||
})
|
||||
const fullEdit = ref(false)
|
||||
|
||||
const displayCode = computed(() => {
|
||||
return props.format === formatTypes.JSON
|
||||
const isCode = computed(() => {
|
||||
return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON
|
||||
})
|
||||
const valueFilterOption = ref(null)
|
||||
const valueColumn = computed(() => ({
|
||||
key: 'value',
|
||||
title: i18n.t('common.value'),
|
||||
align: displayCode.value ? 'left' : 'center',
|
||||
title: () => i18n.t('common.value'),
|
||||
align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',
|
||||
titleAlign: 'center',
|
||||
ellipsis: displayCode.value
|
||||
ellipsis: isCode.value
|
||||
? false
|
||||
: {
|
||||
tooltip: true,
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
lineClamp: 1,
|
||||
},
|
||||
filterOptionValue: valueFilterOption.value,
|
||||
className: inEdit.value ? 'clickable' : '',
|
||||
filter: (value, row) => {
|
||||
if (row.dv) {
|
||||
return !!~row.dv.indexOf(value.toString())
|
||||
}
|
||||
return !!~row.v.indexOf(value.toString())
|
||||
filter: (filterValue, row) => {
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
return !!~val.indexOf(filterValue.toString())
|
||||
},
|
||||
render: (row) => {
|
||||
if (displayCode.value) {
|
||||
return h(NCode, { language: 'json', wordWrap: true, code: row.dv || row.v })
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
if (isCode.value) {
|
||||
return h('pre', { class: 'pre-wrap' }, val)
|
||||
}
|
||||
return row.dv || row.v
|
||||
return val
|
||||
},
|
||||
}))
|
||||
|
||||
const startEdit = async (no, value) => {
|
||||
currentEditRow.no = no
|
||||
currentEditRow.value = value
|
||||
currentEditRow.decode = props.decode
|
||||
currentEditRow.format = props.format
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -136,13 +150,13 @@ const saveEdit = async (pos, value, decode, format) => {
|
|||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
index,
|
||||
index: row.index,
|
||||
value,
|
||||
decode,
|
||||
format,
|
||||
})
|
||||
if (success) {
|
||||
$message.success(i18n.t('dialogue.save_value_succ'))
|
||||
$message.success(i18n.t('interface.save_value_succ'))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -154,33 +168,39 @@ const saveEdit = async (pos, value, decode, format) => {
|
|||
const resetEdit = () => {
|
||||
currentEditRow.no = 0
|
||||
currentEditRow.value = null
|
||||
// if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {
|
||||
// nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))
|
||||
// }
|
||||
}
|
||||
|
||||
const actionColumn = {
|
||||
key: 'action',
|
||||
title: i18n.t('interface.action'),
|
||||
width: 100,
|
||||
title: () => i18n.t('interface.action'),
|
||||
width: 120,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
fixed: 'right',
|
||||
render: (row, index) => {
|
||||
render: ({ index, v }, _) => {
|
||||
return h(EditableTableColumn, {
|
||||
editing: false,
|
||||
bindKey: `#${index + 1}`,
|
||||
onCopy: async () => {
|
||||
copy(v)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
},
|
||||
onEdit: () => {
|
||||
startEdit(index + 1, row.v)
|
||||
startEdit(index + 1, v)
|
||||
},
|
||||
onDelete: async () => {
|
||||
try {
|
||||
const { success, msg } = await browserStore.removeListItem(
|
||||
props.name,
|
||||
props.db,
|
||||
keyName.value,
|
||||
const { success, msg } = await browserStore.removeListItem({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
index,
|
||||
)
|
||||
})
|
||||
if (success) {
|
||||
props.value.splice(index, 1)
|
||||
$message.success(i18n.t('dialogue.delete_key_succ', { key: `#${index + 1}` }))
|
||||
$message.success(i18n.t('dialogue.delete.success', { key: `#${index + 1}` }))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -201,7 +221,7 @@ const columns = computed(() => {
|
|||
width: 80,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row, index) => {
|
||||
render: ({ index }, _) => {
|
||||
return index + 1
|
||||
},
|
||||
},
|
||||
|
@ -216,7 +236,7 @@ const columns = computed(() => {
|
|||
width: 80,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row, index) => {
|
||||
render: ({ index }, _) => {
|
||||
if (index + 1 === currentEditRow.no) {
|
||||
// editing row, show edit state
|
||||
return h(NIcon, { size: 16, color: 'red' }, () => h(Edit, { strokeWidth: 5 }))
|
||||
|
@ -230,12 +250,12 @@ const columns = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
const rowProps = (row, index) => {
|
||||
const rowProps = ({ index, v }, _) => {
|
||||
return {
|
||||
onClick: () => {
|
||||
// in edit mode, switch edit row by click
|
||||
if (inEdit.value) {
|
||||
startEdit(index + 1, row.v)
|
||||
startEdit(index + 1, v)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -246,6 +266,15 @@ const entries = computed(() => {
|
|||
return `${len} / ${Math.max(len, props.length)}`
|
||||
})
|
||||
|
||||
const loadProgress = computed(() => {
|
||||
const len = size(props.value)
|
||||
return (len * 100) / Math.max(len, props.length)
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
const onAddValue = (value) => {
|
||||
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.LIST)
|
||||
}
|
||||
|
@ -278,26 +307,24 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<content-toolbar
|
||||
:db="props.db"
|
||||
:key-code="props.keyCode"
|
||||
:key-path="props.keyPath"
|
||||
:key-type="keyType"
|
||||
:loading="props.loading"
|
||||
:server="props.name"
|
||||
:ttl="ttl"
|
||||
class="value-item-part"
|
||||
@delete="emit('delete')"
|
||||
@reload="emit('reload')"
|
||||
@rename="emit('rename')" />
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-box-h">
|
||||
<div class="flex-box-h" style="max-width: 50%">
|
||||
<content-search-input
|
||||
ref="searchInputRef"
|
||||
@filter-changed="onFilterInput"
|
||||
@match-changed="onMatchInput" />
|
||||
</div>
|
||||
<div class="flex-item-expand"></div>
|
||||
<switch-button
|
||||
:icons="[AlignCenter, AlignLeft]"
|
||||
:stroke-width="3.5"
|
||||
:t-tooltips="['interface.text_align_center', 'interface.text_align_left']"
|
||||
:value="props.textAlign"
|
||||
size="medium"
|
||||
unselect-stroke-width="3"
|
||||
@update:value="(val) => emit('update:textAlign', val)" />
|
||||
<n-divider vertical />
|
||||
<n-button-group>
|
||||
<icon-button
|
||||
:disabled="props.end || props.loading"
|
||||
|
@ -321,6 +348,16 @@ defineExpose({
|
|||
{{ $t('interface.add_row') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- loaded progress -->
|
||||
<n-progress
|
||||
:border-radius="0"
|
||||
:color="props.end ? '#0000' : themeVars.primaryColor"
|
||||
:height="2"
|
||||
:percentage="loadProgress"
|
||||
:processing="props.loading"
|
||||
:show-indicator="false"
|
||||
status="success"
|
||||
type="line" />
|
||||
<div class="value-wrapper value-item-part flex-box-h flex-item-expand">
|
||||
<!-- table -->
|
||||
<n-data-table
|
||||
|
@ -347,12 +384,14 @@ defineExpose({
|
|||
class="entry-editor-container flex-item-expand"
|
||||
style="width: 100%">
|
||||
<content-entry-editor
|
||||
v-model:decode="currentEditRow.decode"
|
||||
v-model:format="currentEditRow.format"
|
||||
v-model:fullscreen="fullEdit"
|
||||
:decode="currentEditRow.decode"
|
||||
:field="currentEditRow.no"
|
||||
:field-label="$t('common.index')"
|
||||
:field-readonly="true"
|
||||
:format="currentEditRow.format"
|
||||
:key-path="props.keyPath"
|
||||
:show="inEdit"
|
||||
:value="currentEditRow.value"
|
||||
:value-label="$t('common.value')"
|
||||
class="flex-item-expand"
|
||||
|
@ -363,8 +402,8 @@ defineExpose({
|
|||
</div>
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="!isNaN(props.length)">{{ $t('interface.entries') }}: {{ entries }}</n-text>
|
||||
<n-divider v-if="!isNaN(props.length)" vertical />
|
||||
<n-text v-if="!isNaN(props.size)">{{ $t('interface.memory_usage') }}: {{ bytes(props.size) }}</n-text>
|
||||
<n-divider v-if="showMemoryUsage" vertical />
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand"></div>
|
||||
<format-selector
|
||||
v-show="!inEdit"
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
<script setup>
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from './ContentToolbar.vue'
|
||||
import AddLink from '@/components/icons/AddLink.vue'
|
||||
import { NButton, NCode, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { isEmpty, size } from 'lodash'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import { types, types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import EditableTableColumn from '@/components/common/EditableTableColumn.vue'
|
||||
import bytes from 'bytes'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import LoadList from '@/components/icons/LoadList.vue'
|
||||
|
@ -18,6 +16,13 @@ import Edit from '@/components/icons/Edit.vue'
|
|||
import ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vue'
|
||||
import FormatSelector from '@/components/content_value/FormatSelector.vue'
|
||||
import ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
import AlignLeft from '@/components/icons/AlignLeft.vue'
|
||||
import AlignCenter from '@/components/icons/AlignCenter.vue'
|
||||
import SwitchButton from '@/components/common/SwitchButton.vue'
|
||||
import { TextAlignType } from '@/consts/text_align_type.js'
|
||||
import { nativeRedisKey } from '@/utils/key_convert.js'
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
@ -50,9 +55,10 @@ const props = defineProps({
|
|||
},
|
||||
end: Boolean,
|
||||
loading: Boolean,
|
||||
textAlign: Number,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'rename', 'delete', 'match'])
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -76,39 +82,47 @@ const inEdit = computed(() => {
|
|||
})
|
||||
const fullEdit = ref(false)
|
||||
|
||||
const displayCode = computed(() => {
|
||||
return props.format === formatTypes.JSON
|
||||
const isCode = computed(() => {
|
||||
return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON
|
||||
})
|
||||
const valueFilterOption = ref(null)
|
||||
const valueColumn = computed(() => ({
|
||||
key: 'value',
|
||||
title: i18n.t('common.value'),
|
||||
align: displayCode.value ? 'left' : 'center',
|
||||
title: () => i18n.t('common.value'),
|
||||
align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',
|
||||
titleAlign: 'center',
|
||||
ellipsis: displayCode.value
|
||||
ellipsis: isCode.value
|
||||
? false
|
||||
: {
|
||||
tooltip: true,
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
lineClamp: 1,
|
||||
},
|
||||
filterOptionValue: valueFilterOption.value,
|
||||
className: inEdit.value ? 'clickable' : '',
|
||||
filter: (value, row) => {
|
||||
if (row.dv) {
|
||||
return !!~row.dv.indexOf(value.toString())
|
||||
}
|
||||
return !!~row.v.indexOf(value.toString())
|
||||
filter: (filterValue, row) => {
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
return !!~val.indexOf(filterValue.toString())
|
||||
},
|
||||
render: (row) => {
|
||||
if (displayCode.value) {
|
||||
return h(NCode, { language: 'json', wordWrap: true, code: row.dv || row.v })
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
if (isCode.value) {
|
||||
return h('pre', { class: 'pre-wrap' }, val)
|
||||
}
|
||||
return row.dv || row.v
|
||||
return val
|
||||
},
|
||||
}))
|
||||
|
||||
const startEdit = async (no, value) => {
|
||||
currentEditRow.no = no
|
||||
currentEditRow.value = value
|
||||
currentEditRow.decode = props.decode
|
||||
currentEditRow.format = props.format
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -139,7 +153,7 @@ const saveEdit = async (pos, value, decode, format) => {
|
|||
retFormat: props.format,
|
||||
})
|
||||
if (success) {
|
||||
$message.success(i18n.t('dialogue.save_value_succ'))
|
||||
$message.success(i18n.t('interface.save_value_succ'))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -151,12 +165,15 @@ const saveEdit = async (pos, value, decode, format) => {
|
|||
const resetEdit = () => {
|
||||
currentEditRow.no = 0
|
||||
currentEditRow.value = null
|
||||
// if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {
|
||||
// nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))
|
||||
// }
|
||||
}
|
||||
|
||||
const actionColumn = {
|
||||
key: 'action',
|
||||
title: i18n.t('interface.action'),
|
||||
width: 100,
|
||||
title: () => i18n.t('interface.action'),
|
||||
width: 120,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
fixed: 'right',
|
||||
|
@ -164,20 +181,23 @@ const actionColumn = {
|
|||
return h(EditableTableColumn, {
|
||||
editing: false,
|
||||
bindKey: `#${index + 1}`,
|
||||
onCopy: async () => {
|
||||
copy(row.v)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
},
|
||||
onEdit: () => {
|
||||
startEdit(index + 1, row.v)
|
||||
},
|
||||
onDelete: async () => {
|
||||
try {
|
||||
const { success, msg } = await browserStore.removeSetItem(
|
||||
props.name,
|
||||
props.db,
|
||||
keyName.value,
|
||||
row.v,
|
||||
)
|
||||
const { success, msg } = await browserStore.removeSetItem({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
value: row.v,
|
||||
})
|
||||
if (success) {
|
||||
// props.value.splice(index, 1)
|
||||
$message.success(i18n.t('dialogue.delete_key_succ', { key: row.v }))
|
||||
$message.success(i18n.t('dialogue.delete.success', { key: row.v }))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -243,6 +263,15 @@ const entries = computed(() => {
|
|||
return `${len} / ${Math.max(len, props.length)}`
|
||||
})
|
||||
|
||||
const loadProgress = computed(() => {
|
||||
const len = size(props.value)
|
||||
return (len * 100) / Math.max(len, props.length)
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
const onAddValue = (value) => {
|
||||
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.SET)
|
||||
}
|
||||
|
@ -275,26 +304,24 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<content-toolbar
|
||||
:db="props.db"
|
||||
:key-code="props.keyCode"
|
||||
:key-path="props.keyPath"
|
||||
:key-type="keyType"
|
||||
:loading="props.loading"
|
||||
:server="props.name"
|
||||
:ttl="ttl"
|
||||
class="value-item-part"
|
||||
@delete="emit('delete')"
|
||||
@reload="emit('reload')"
|
||||
@rename="emit('rename')" />
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-box-h">
|
||||
<div class="flex-box-h" style="max-width: 50%">
|
||||
<content-search-input
|
||||
ref="searchInputRef"
|
||||
@filter-changed="onFilterInput"
|
||||
@match-changed="onMatchInput" />
|
||||
</div>
|
||||
<div class="flex-item-expand"></div>
|
||||
<switch-button
|
||||
:icons="[AlignCenter, AlignLeft]"
|
||||
:stroke-width="3.5"
|
||||
:t-tooltips="['interface.text_align_center', 'interface.text_align_left']"
|
||||
:value="props.textAlign"
|
||||
size="medium"
|
||||
unselect-stroke-width="3"
|
||||
@update:value="(val) => emit('update:textAlign', val)" />
|
||||
<n-divider vertical />
|
||||
<n-button-group>
|
||||
<icon-button
|
||||
:disabled="props.end || props.loading"
|
||||
|
@ -318,6 +345,16 @@ defineExpose({
|
|||
{{ $t('interface.add_row') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- loaded progress -->
|
||||
<n-progress
|
||||
:border-radius="0"
|
||||
:color="props.end ? '#0000' : themeVars.primaryColor"
|
||||
:height="2"
|
||||
:percentage="loadProgress"
|
||||
:processing="props.loading"
|
||||
:show-indicator="false"
|
||||
status="success"
|
||||
type="line" />
|
||||
<div class="value-wrapper value-item-part flex-box-h flex-item-expand">
|
||||
<!-- table -->
|
||||
<n-data-table
|
||||
|
@ -344,12 +381,14 @@ defineExpose({
|
|||
class="entry-editor-container flex-item-expand"
|
||||
style="width: 100%">
|
||||
<content-entry-editor
|
||||
v-model:decode="currentEditRow.decode"
|
||||
v-model:format="currentEditRow.format"
|
||||
v-model:fullscreen="fullEdit"
|
||||
:decode="currentEditRow.decode"
|
||||
:field="currentEditRow.no"
|
||||
:field-label="$t('common.index')"
|
||||
:field-readonly="true"
|
||||
:format="currentEditRow.format"
|
||||
:key-path="props.keyPath"
|
||||
:show="inEdit"
|
||||
:value="currentEditRow.value"
|
||||
:value-label="$t('common.value')"
|
||||
class="flex-item-expand"
|
||||
|
@ -360,8 +399,8 @@ defineExpose({
|
|||
</div>
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="!isNaN(props.length)">{{ $t('interface.entries') }}: {{ entries }}</n-text>
|
||||
<n-divider v-if="!isNaN(props.length)" vertical />
|
||||
<n-text v-if="!isNaN(props.size)">{{ $t('interface.memory_usage') }}: {{ bytes(props.size) }}</n-text>
|
||||
<n-divider v-if="showMemoryUsage" vertical />
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand"></div>
|
||||
<format-selector
|
||||
v-show="!inEdit"
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
<script setup>
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from './ContentToolbar.vue'
|
||||
import AddLink from '@/components/icons/AddLink.vue'
|
||||
import { NButton, NCode, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { types, types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import EditableTableColumn from '@/components/common/EditableTableColumn.vue'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import { includes, isEmpty, size } from 'lodash'
|
||||
import bytes from 'bytes'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import LoadList from '@/components/icons/LoadList.vue'
|
||||
import LoadAll from '@/components/icons/LoadAll.vue'
|
||||
import IconButton from '@/components/common/IconButton.vue'
|
||||
import ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
@ -49,7 +49,7 @@ const props = defineProps({
|
|||
loading: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'rename', 'delete', 'match'])
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'match'])
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -74,7 +74,7 @@ const idColumn = computed(() => ({
|
|||
const valueFilterOption = ref(null)
|
||||
const valueColumn = computed(() => ({
|
||||
key: 'value',
|
||||
title: i18n.t('common.value'),
|
||||
title: () => i18n.t('common.value'),
|
||||
align: 'left',
|
||||
titleAlign: 'center',
|
||||
resizable: true,
|
||||
|
@ -93,13 +93,13 @@ const valueColumn = computed(() => ({
|
|||
},
|
||||
// sorter: (row1, row2) => row1.value - row2.value,
|
||||
render: (row) => {
|
||||
return h(NCode, { language: 'json', wordWrap: true, code: row.dv })
|
||||
return h('pre', { class: 'pre-wrap' }, row.dv)
|
||||
},
|
||||
}))
|
||||
const actionColumn = {
|
||||
key: 'action',
|
||||
title: i18n.t('interface.action'),
|
||||
width: 60,
|
||||
title: () => i18n.t('interface.action'),
|
||||
width: 80,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
fixed: 'right',
|
||||
|
@ -107,16 +107,20 @@ const actionColumn = {
|
|||
return h(EditableTableColumn, {
|
||||
bindKey: row.id,
|
||||
readonly: true,
|
||||
onCopy: async () => {
|
||||
copy(JSON.stringify(row.v))
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
},
|
||||
onDelete: async () => {
|
||||
try {
|
||||
const { success, msg } = await browserStore.removeStreamValues(
|
||||
props.name,
|
||||
props.db,
|
||||
keyName.value,
|
||||
row.id,
|
||||
)
|
||||
const { success, msg } = await browserStore.removeStreamValues({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
ids: row.id,
|
||||
})
|
||||
if (success) {
|
||||
$message.success(i18n.t('dialogue.delete_key_succ', { key: row.id }))
|
||||
$message.success(i18n.t('dialogue.delete.success', { key: row.id }))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -134,6 +138,15 @@ const entries = computed(() => {
|
|||
return `${len} / ${Math.max(len, props.length)}`
|
||||
})
|
||||
|
||||
const loadProgress = computed(() => {
|
||||
const len = size(props.value)
|
||||
return (len * 100) / Math.max(len, props.length)
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
const onAddRow = () => {
|
||||
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.STREAM)
|
||||
}
|
||||
|
@ -161,34 +174,9 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<content-toolbar
|
||||
:db="props.db"
|
||||
:key-code="props.keyCode"
|
||||
:key-path="props.keyPath"
|
||||
:key-type="keyType"
|
||||
:loading="props.loading"
|
||||
:server="props.name"
|
||||
:ttl="ttl"
|
||||
class="value-item-part"
|
||||
@delete="emit('delete')"
|
||||
@reload="emit('reload')"
|
||||
@rename="emit('rename')" />
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-box-h">
|
||||
<!-- <n-input-group>-->
|
||||
<!-- <n-select-->
|
||||
<!-- v-model:value="filterType"-->
|
||||
<!-- :consistent-menu-width="false"-->
|
||||
<!-- :options="filterOption"-->
|
||||
<!-- style="width: 120px"-->
|
||||
<!-- @update:value="onChangeFilterType" />-->
|
||||
<!-- <n-input-->
|
||||
<!-- v-model:value="filterValue"-->
|
||||
<!-- :placeholder="$t('interface.search')"-->
|
||||
<!-- clearable-->
|
||||
<!-- @clear="clearFilter"-->
|
||||
<!-- @update:value="onFilterInput" />-->
|
||||
<!-- </n-input-group>-->
|
||||
<content-search-input
|
||||
ref="searchInputRef"
|
||||
@filter-changed="onFilterInput"
|
||||
|
@ -218,6 +206,16 @@ defineExpose({
|
|||
{{ $t('interface.add_row') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- loaded progress -->
|
||||
<n-progress
|
||||
:border-radius="0"
|
||||
:color="props.end ? '#0000' : themeVars.primaryColor"
|
||||
:height="2"
|
||||
:percentage="loadProgress"
|
||||
:processing="props.loading"
|
||||
:show-indicator="false"
|
||||
status="success"
|
||||
type="line" />
|
||||
<div class="value-wrapper value-item-part flex-box-v flex-item-expand">
|
||||
<n-data-table
|
||||
:bordered="false"
|
||||
|
@ -238,8 +236,8 @@ defineExpose({
|
|||
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="!isNaN(props.length)">{{ $t('interface.entries') }}: {{ entries }}</n-text>
|
||||
<n-divider v-if="!isNaN(props.length)" vertical />
|
||||
<n-text v-if="!isNaN(props.size)">{{ $t('interface.memory_usage') }}: {{ bytes(props.size) }}</n-text>
|
||||
<n-divider v-if="showMemoryUsage" vertical />
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from './ContentToolbar.vue'
|
||||
import Copy from '@/components/icons/Copy.vue'
|
||||
import Save from '@/components/icons/Save.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import Close from '@/components/icons/Close.vue'
|
||||
import { formatTypes } from '@/consts/value_view_type.js'
|
||||
import { types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import { ClipboardSetText } from 'wailsjs/runtime/runtime.js'
|
||||
import { isEmpty, toLower } from 'lodash'
|
||||
import EditFile from '@/components/icons/EditFile.vue'
|
||||
import bytes from 'bytes'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import { decodeRedisKey } from '@/utils/key_convert.js'
|
||||
import FormatSelector from '@/components/content_value/FormatSelector.vue'
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
import ContentEditor from '@/components/content_value/ContentEditor.vue'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
|
||||
const props = defineProps({
|
||||
name: String,
|
||||
|
@ -32,12 +27,19 @@ const props = defineProps({
|
|||
default: -1,
|
||||
},
|
||||
value: [String, Array],
|
||||
format: {
|
||||
type: String,
|
||||
},
|
||||
decode: {
|
||||
type: String,
|
||||
},
|
||||
size: Number,
|
||||
length: Number,
|
||||
loading: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reload', 'rename', 'delete'])
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -51,7 +53,12 @@ const keyType = redisTypes.STRING
|
|||
const viewLanguage = computed(() => {
|
||||
switch (viewAs.format) {
|
||||
case formatTypes.JSON:
|
||||
case formatTypes.UNICODE_JSON:
|
||||
return 'json'
|
||||
case formatTypes.YAML:
|
||||
return 'yaml'
|
||||
case formatTypes.XML:
|
||||
return 'xml'
|
||||
default:
|
||||
return 'plaintext'
|
||||
}
|
||||
|
@ -59,65 +66,63 @@ const viewLanguage = computed(() => {
|
|||
|
||||
const viewAs = reactive({
|
||||
value: '',
|
||||
format: formatTypes.RAW,
|
||||
decode: decodeTypes.NONE,
|
||||
format: '',
|
||||
decode: '',
|
||||
})
|
||||
|
||||
const editingContent = ref('')
|
||||
const resetKey = ref('')
|
||||
|
||||
const enableSave = computed(() => {
|
||||
return editingContent.value !== viewAs.value && !props.loading
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.loading) {
|
||||
return ''
|
||||
}
|
||||
return viewAs.value || decodeRedisKey(props.value)
|
||||
return viewAs.value || decodeRedisKey(props.value) || ''
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(val, oldVal) => {
|
||||
if (val !== undefined && oldVal !== undefined) {
|
||||
(val) => {
|
||||
if (!isEmpty(val)) {
|
||||
onFormatChanged(viewAs.decode, viewAs.format)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const converting = ref(false)
|
||||
const onFormatChanged = async (decode = '', format = '') => {
|
||||
const {
|
||||
value,
|
||||
decode: retDecode,
|
||||
format: retFormat,
|
||||
} = await browserStore.convertValue({
|
||||
value: props.value,
|
||||
decode,
|
||||
format,
|
||||
})
|
||||
viewAs.value = value
|
||||
viewAs.decode = decode || retDecode
|
||||
viewAs.format = format || retFormat
|
||||
try {
|
||||
converting.value = true
|
||||
const {
|
||||
value,
|
||||
decode: retDecode,
|
||||
format: retFormat,
|
||||
} = await browserStore.convertValue({
|
||||
value: props.value,
|
||||
decode: decode || props.decode,
|
||||
format: format || props.format,
|
||||
})
|
||||
editingContent.value = viewAs.value = value
|
||||
viewAs.decode = decode || retDecode
|
||||
viewAs.format = format || retFormat
|
||||
browserStore.setSelectedFormat(props.name, props.keyPath, props.db, viewAs.format, viewAs.decode)
|
||||
resetKey.value = Date.now().toString()
|
||||
} finally {
|
||||
converting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy value
|
||||
*/
|
||||
const onCopyValue = () => {
|
||||
ClipboardSetText(displayValue.value)
|
||||
.then((succ) => {
|
||||
if (succ) {
|
||||
$message.success(i18n.t('dialogue.copy_succ'))
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
$message.error(e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const editValue = ref('')
|
||||
const inEdit = ref(false)
|
||||
const onEditValue = () => {
|
||||
editValue.value = displayValue.value
|
||||
inEdit.value = true
|
||||
}
|
||||
|
||||
const onCancelEdit = () => {
|
||||
inEdit.value = false
|
||||
copy(displayValue.value)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -125,7 +130,12 @@ const onCancelEdit = () => {
|
|||
*/
|
||||
const browserStore = useBrowserStore()
|
||||
const saving = ref(false)
|
||||
const onSaveValue = async () => {
|
||||
|
||||
const onInput = (content) => {
|
||||
editingContent.value = content
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const { success, msg } = await browserStore.setKey({
|
||||
|
@ -133,21 +143,20 @@ const onSaveValue = async () => {
|
|||
db: props.db,
|
||||
key: keyName.value,
|
||||
keyType: toLower(keyType),
|
||||
value: editValue.value,
|
||||
value: editingContent.value,
|
||||
ttl: -1,
|
||||
format: viewAs.format,
|
||||
decode: viewAs.decode,
|
||||
})
|
||||
if (success) {
|
||||
await browserStore.loadKeyDetail({ server: props.name, db: props.db, key: keyName.value })
|
||||
$message.success(i18n.t('dialogue.save_value_succ'))
|
||||
viewAs.value = editingContent.value
|
||||
$message.success(i18n.t('interface.save_value_succ'))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
} catch (e) {
|
||||
$message.error(e.message)
|
||||
} finally {
|
||||
inEdit.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
@ -155,77 +164,61 @@ const onSaveValue = async () => {
|
|||
defineExpose({
|
||||
reset: () => {
|
||||
viewAs.value = ''
|
||||
inEdit.value = false
|
||||
viewAs.decode = ''
|
||||
viewAs.format = ''
|
||||
editingContent.value = ''
|
||||
},
|
||||
beforeShow: () => onFormatChanged(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<content-toolbar
|
||||
:db="props.db"
|
||||
:key-code="keyCode"
|
||||
:key-path="keyPath"
|
||||
:key-type="keyType"
|
||||
:loading="loading"
|
||||
:server="props.name"
|
||||
:ttl="ttl"
|
||||
class="value-item-part"
|
||||
@delete="emit('delete')"
|
||||
@reload="emit('reload')"
|
||||
@rename="emit('rename')" />
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-item-expand"></div>
|
||||
<n-button-group v-if="!inEdit">
|
||||
<n-button :focusable="false" @click="onCopyValue">
|
||||
<n-button-group>
|
||||
<n-button :disabled="saving" :focusable="false" @click="onCopyValue">
|
||||
<template #icon>
|
||||
<n-icon :component="Copy" size="18" />
|
||||
</template>
|
||||
{{ $t('interface.copy_value') }}
|
||||
</n-button>
|
||||
<n-button :focusable="false" plain @click="onEditValue">
|
||||
<template #icon>
|
||||
<n-icon :component="EditFile" size="18" />
|
||||
</template>
|
||||
{{ $t('interface.edit_value') }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
<n-button-group v-else>
|
||||
<n-button :focusable="false" :loading="saving" plain @click="onSaveValue">
|
||||
<n-button
|
||||
:disabled="!enableSave"
|
||||
:loading="saving"
|
||||
:secondary="enableSave"
|
||||
:type="enableSave ? 'primary' : ''"
|
||||
@click="onSave">
|
||||
<template #icon>
|
||||
<n-icon :component="Save" size="18" />
|
||||
</template>
|
||||
{{ $t('interface.save_update') }}
|
||||
</n-button>
|
||||
<n-button :focusable="false" :loading="saving" plain @click="onCancelEdit">
|
||||
<template #icon>
|
||||
<n-icon :component="Close" size="18" />
|
||||
</template>
|
||||
{{ $t('common.cancel') }}
|
||||
{{ $t('common.save') }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
<div class="value-wrapper value-item-part flex-item-expand flex-box-v">
|
||||
<n-scrollbar v-if="!inEdit" class="flex-item-expand">
|
||||
<n-code :code="displayValue" :language="viewLanguage" style="cursor: text" word-wrap />
|
||||
</n-scrollbar>
|
||||
<n-input
|
||||
v-else
|
||||
v-model:value="editValue"
|
||||
:disabled="saving"
|
||||
:resizable="false"
|
||||
<content-editor
|
||||
:content="displayValue"
|
||||
:language="viewLanguage"
|
||||
:loading="props.loading"
|
||||
:offset-key="props.keyPath"
|
||||
:reset-key="resetKey"
|
||||
class="flex-item-expand"
|
||||
type="textarea" />
|
||||
keep-offset
|
||||
style="height: 100%"
|
||||
@input="onInput"
|
||||
@reset="onInput"
|
||||
@save="onSave" />
|
||||
<n-spin v-show="props.loading || converting" />
|
||||
</div>
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="!isNaN(props.length)">{{ $t('interface.length') }}: {{ props.length }}</n-text>
|
||||
<n-divider v-if="!isNaN(props.length)" vertical />
|
||||
<n-text v-if="!isNaN(props.size)">{{ $t('interface.memory_usage') }}: {{ bytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand"></div>
|
||||
<n-divider v-if="showMemoryUsage" vertical />
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand" />
|
||||
<format-selector
|
||||
:decode="viewAs.decode"
|
||||
:disabled="inEdit"
|
||||
:disabled="enableSave"
|
||||
:format="viewAs.format"
|
||||
@format-changed="onFormatChanged" />
|
||||
</div>
|
||||
|
@ -234,7 +227,7 @@ defineExpose({
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.value-wrapper {
|
||||
overflow: hidden;
|
||||
//overflow: hidden;
|
||||
border-top: v-bind('themeVars.borderColor') 1px solid;
|
||||
padding: 5px;
|
||||
}
|
||||
|
|
|
@ -11,11 +11,17 @@ import useBrowserStore from 'stores/browser.js'
|
|||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { isEmpty } from 'lodash'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from '@/components/content_value/ContentToolbar.vue'
|
||||
import ContentValueJson from '@/components/content_value/ContentValueJson.vue'
|
||||
import usePreferencesStore from 'stores/preferences.js'
|
||||
import { TextAlignType } from '@/consts/text_align_type.js'
|
||||
import { isMacOS } from '@/utils/platform.js'
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
const browserStore = useBrowserStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const prefStore = usePreferencesStore()
|
||||
|
||||
const props = defineProps({
|
||||
blank: Boolean,
|
||||
|
@ -24,6 +30,7 @@ const props = defineProps({
|
|||
default: {},
|
||||
},
|
||||
})
|
||||
const i18n = useI18n()
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -40,6 +47,7 @@ const props = defineProps({
|
|||
* format: String,
|
||||
* decode: String,
|
||||
* end: Boolean
|
||||
* loading: Boolean
|
||||
* }>}
|
||||
*/
|
||||
const data = computed(() => {
|
||||
|
@ -47,6 +55,10 @@ const data = computed(() => {
|
|||
})
|
||||
const initializing = ref(false)
|
||||
|
||||
const loading = computed(() => {
|
||||
return data.value.loading === true || initializing.value
|
||||
})
|
||||
|
||||
const binaryKey = computed(() => {
|
||||
return !!data.value.keyCode
|
||||
})
|
||||
|
@ -58,6 +70,7 @@ const valueComponents = {
|
|||
[redisTypes.SET]: ContentValueSet,
|
||||
[redisTypes.ZSET]: ContentValueZset,
|
||||
[redisTypes.STREAM]: ContentValueStream,
|
||||
[redisTypes.JSON]: ContentValueJson,
|
||||
}
|
||||
|
||||
const keyName = computed(() => {
|
||||
|
@ -76,15 +89,15 @@ const loadData = async (reset, full, selMatch) => {
|
|||
if (!!props.blank) {
|
||||
return
|
||||
}
|
||||
const { name, db, matchPattern, decode, format } = data.value
|
||||
const { name, db, matchPattern } = data.value
|
||||
reset = reset === true
|
||||
await browserStore.loadKeyDetail({
|
||||
server: name,
|
||||
db: db,
|
||||
key: keyName.value,
|
||||
matchPattern: selMatch === undefined ? matchPattern : selMatch,
|
||||
decode: reset ? decodeTypes.NONE : decode,
|
||||
format: reset ? formatTypes.RAW : format,
|
||||
decode: '',
|
||||
format: '',
|
||||
reset,
|
||||
full: full === true,
|
||||
})
|
||||
|
@ -101,18 +114,42 @@ const loadData = async (reset, full, selMatch) => {
|
|||
const onReload = async (selDecode, selFormat) => {
|
||||
try {
|
||||
const { name, db, keyCode, keyPath, decode, format, matchPattern } = data.value
|
||||
const targetFormat = selFormat || format
|
||||
const targetDecode = selDecode || decode
|
||||
browserStore.setSelectedFormat(name, keyPath, db, targetFormat, targetDecode)
|
||||
await browserStore.reloadKey({
|
||||
server: name,
|
||||
db,
|
||||
key: keyCode || keyPath,
|
||||
decode: selDecode || decode,
|
||||
format: selFormat || format,
|
||||
decode: targetDecode,
|
||||
format: targetFormat,
|
||||
matchPattern,
|
||||
showLoading: false,
|
||||
})
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyShortcut = (e) => {
|
||||
const isCtrlOn = isMacOS() ? e.metaKey : e.ctrlKey
|
||||
switch (e.key) {
|
||||
case 'Delete':
|
||||
onDelete()
|
||||
return
|
||||
case 'F5':
|
||||
onReload()
|
||||
return
|
||||
case 'r':
|
||||
if (isCtrlOn) {
|
||||
onReload()
|
||||
}
|
||||
return
|
||||
case 'F2':
|
||||
onRename()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const onRename = () => {
|
||||
const { name, db, keyPath } = data.value
|
||||
if (binaryKey.value) {
|
||||
|
@ -123,11 +160,11 @@ const onRename = () => {
|
|||
}
|
||||
|
||||
const onDelete = () => {
|
||||
$dialog.warning(i18n.t('dialogue.remove_tip', { name: props.keyPath }), () => {
|
||||
$dialog.warning(i18n.t('dialogue.remove_tip', { name: data.value.keyPath }), () => {
|
||||
const { name, db } = data.value
|
||||
browserStore.deleteKey(name, db, keyName.value).then((success) => {
|
||||
if (success) {
|
||||
$message.success(i18n.t('dialogue.delete_key_succ', { key: props.keyPath }))
|
||||
$message.success(i18n.t('dialogue.delete.success', { key: data.value.keyPath }))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -145,6 +182,11 @@ const onMatch = (match) => {
|
|||
loadData(true, false, match || '')
|
||||
}
|
||||
|
||||
const onEntryTextAlignChanged = (align) => {
|
||||
prefStore.editor.entryTextAlign = align !== TextAlignType.Left ? TextAlignType.Center : TextAlignType.Left
|
||||
prefStore.savePreferences()
|
||||
}
|
||||
|
||||
const contentRef = ref(null)
|
||||
const initContent = async () => {
|
||||
// onReload()
|
||||
|
@ -154,9 +196,6 @@ const initContent = async () => {
|
|||
contentRef.value?.reset()
|
||||
}
|
||||
await loadData(true, false, '')
|
||||
if (contentRef.value?.beforeShow != null) {
|
||||
await contentRef.value?.beforeShow()
|
||||
}
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
|
@ -189,17 +228,35 @@ watch(() => data.value?.keyPath, initContent)
|
|||
:key-code="data.keyCode"
|
||||
:key-path="data.keyPath"
|
||||
:length="data.length"
|
||||
:loading="data.loading === true || initializing"
|
||||
:loading="loading"
|
||||
:name="data.name"
|
||||
:size="data.size"
|
||||
:ttl="data.ttl"
|
||||
:value="data.value"
|
||||
tabindex="0"
|
||||
:text-align="prefStore.entryTextAlign"
|
||||
@delete="onDelete"
|
||||
@keydown="onKeyShortcut"
|
||||
@loadall="onLoadAll"
|
||||
@loadmore="onLoadMore"
|
||||
@match="onMatch"
|
||||
@reload="onReload"
|
||||
@rename="onRename" />
|
||||
@update:text-align="onEntryTextAlignChanged">
|
||||
<template #toolbar>
|
||||
<content-toolbar
|
||||
:db="data.db"
|
||||
:key-code="data.keyCode"
|
||||
:key-path="data.keyPath"
|
||||
:key-type="data.type"
|
||||
:loading="loading"
|
||||
:server="data.name"
|
||||
:ttl="data.ttl"
|
||||
class="value-item-part"
|
||||
@delete="onDelete"
|
||||
@reload="onReload"
|
||||
@rename="onRename" />
|
||||
</template>
|
||||
</component>
|
||||
<!-- </keep-alive>-->
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
<script setup>
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContentToolbar from './ContentToolbar.vue'
|
||||
import AddLink from '@/components/icons/AddLink.vue'
|
||||
import { NButton, NCode, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NIcon, useThemeVars } from 'naive-ui'
|
||||
import { types, types as redisTypes } from '@/consts/support_redis_type.js'
|
||||
import EditableTableColumn from '@/components/common/EditableTableColumn.vue'
|
||||
import { isEmpty, size } from 'lodash'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
import bytes from 'bytes'
|
||||
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
||||
import useBrowserStore from 'stores/browser.js'
|
||||
import LoadList from '@/components/icons/LoadList.vue'
|
||||
|
@ -18,6 +16,13 @@ import ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vu
|
|||
import FormatSelector from '@/components/content_value/FormatSelector.vue'
|
||||
import Edit from '@/components/icons/Edit.vue'
|
||||
import ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'
|
||||
import { formatBytes } from '@/utils/byte_convert.js'
|
||||
import copy from 'copy-text-to-clipboard'
|
||||
import { TextAlignType } from '@/consts/text_align_type.js'
|
||||
import AlignLeft from '@/components/icons/AlignLeft.vue'
|
||||
import AlignCenter from '@/components/icons/AlignCenter.vue'
|
||||
import SwitchButton from '@/components/common/SwitchButton.vue'
|
||||
import { nativeRedisKey } from '@/utils/key_convert.js'
|
||||
|
||||
const i18n = useI18n()
|
||||
const themeVars = useThemeVars()
|
||||
|
@ -50,9 +55,10 @@ const props = defineProps({
|
|||
},
|
||||
end: Boolean,
|
||||
loading: Boolean,
|
||||
textAlign: Number,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'rename', 'delete', 'match'])
|
||||
const emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -81,8 +87,8 @@ const fullEdit = ref(false)
|
|||
// const scoreFilterOption = ref(null)
|
||||
const scoreColumn = computed(() => ({
|
||||
key: 'score',
|
||||
title: i18n.t('common.score'),
|
||||
align: 'center',
|
||||
title: () => i18n.t('common.score'),
|
||||
align: props.textAlign !== TextAlignType.Left ? 'center' : 'left',
|
||||
titleAlign: 'center',
|
||||
resizable: true,
|
||||
sorter: (row1, row2) => row1.s - row2.s,
|
||||
|
@ -120,39 +126,45 @@ const scoreColumn = computed(() => ({
|
|||
// return true
|
||||
// },
|
||||
render: (row) => {
|
||||
return row.s
|
||||
return row.ss || row.s
|
||||
},
|
||||
}))
|
||||
|
||||
const displayCode = computed(() => {
|
||||
return props.format === formatTypes.JSON
|
||||
const isCode = computed(() => {
|
||||
return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON
|
||||
})
|
||||
const valueFilterOption = ref(null)
|
||||
const valueColumn = computed(() => ({
|
||||
key: 'value',
|
||||
title: i18n.t('common.value'),
|
||||
align: displayCode.value ? 'left' : 'center',
|
||||
title: () => i18n.t('common.value'),
|
||||
align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',
|
||||
titleAlign: 'center',
|
||||
resizable: true,
|
||||
ellipsis: displayCode.value
|
||||
ellipsis: isCode.value
|
||||
? false
|
||||
: {
|
||||
tooltip: true,
|
||||
tooltip: {
|
||||
style: {
|
||||
maxWidth: '50vw',
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
scrollable: true,
|
||||
},
|
||||
lineClamp: 1,
|
||||
},
|
||||
filterOptionValue: valueFilterOption.value,
|
||||
className: inEdit.value ? 'clickable' : '',
|
||||
filter(value, row) {
|
||||
if (row.dv) {
|
||||
return !!~row.dv.indexOf(value.toString())
|
||||
}
|
||||
return !!~row.v.indexOf(value.toString())
|
||||
filter(filterValue, row) {
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
return !!~val.indexOf(filterValue.toString())
|
||||
},
|
||||
// sorter: (row1, row2) => row1.value - row2.value,
|
||||
render: (row) => {
|
||||
if (displayCode.value) {
|
||||
return h(NCode, { language: 'json', wordWrap: true, code: row.dv || row.v })
|
||||
const val = row.dv || nativeRedisKey(row.v)
|
||||
if (isCode.value) {
|
||||
return h('pre', { class: 'pre-wrap' }, val)
|
||||
}
|
||||
return row.dv || row.v
|
||||
return val
|
||||
},
|
||||
}))
|
||||
|
||||
|
@ -160,6 +172,8 @@ const startEdit = async (no, score, value) => {
|
|||
currentEditRow.no = no
|
||||
currentEditRow.score = score
|
||||
currentEditRow.value = value
|
||||
currentEditRow.decode = props.decode
|
||||
currentEditRow.format = props.format
|
||||
}
|
||||
|
||||
const saveEdit = async (field, value, decode, format) => {
|
||||
|
@ -185,7 +199,7 @@ const saveEdit = async (field, value, decode, format) => {
|
|||
format,
|
||||
})
|
||||
if (success) {
|
||||
$message.success(i18n.t('dialogue.save_value_succ'))
|
||||
$message.success(i18n.t('interface.save_value_succ'))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -198,14 +212,15 @@ const resetEdit = () => {
|
|||
currentEditRow.no = 0
|
||||
currentEditRow.score = 0
|
||||
currentEditRow.value = null
|
||||
currentEditRow.format = formatTypes.RAW
|
||||
currentEditRow.decode = decodeTypes.NONE
|
||||
// if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {
|
||||
// nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))
|
||||
// }
|
||||
}
|
||||
|
||||
const actionColumn = {
|
||||
key: 'action',
|
||||
title: i18n.t('interface.action'),
|
||||
width: 100,
|
||||
title: () => i18n.t('interface.action'),
|
||||
width: 120,
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
fixed: 'right',
|
||||
|
@ -213,18 +228,21 @@ const actionColumn = {
|
|||
return h(EditableTableColumn, {
|
||||
editing: false,
|
||||
bindKey: row.v,
|
||||
onCopy: async () => {
|
||||
copy(row.v)
|
||||
$message.success(i18n.t('interface.copy_succ'))
|
||||
},
|
||||
onEdit: () => startEdit(index + 1, row.s, row.v),
|
||||
onDelete: async () => {
|
||||
try {
|
||||
const { success, msg } = await browserStore.removeZSetItem(
|
||||
props.name,
|
||||
props.db,
|
||||
keyName.value,
|
||||
row.v,
|
||||
)
|
||||
const { success, msg } = await browserStore.removeZSetItem({
|
||||
server: props.name,
|
||||
db: props.db,
|
||||
key: keyName.value,
|
||||
value: row.v,
|
||||
})
|
||||
if (success) {
|
||||
// props.value.splice(index, 1)
|
||||
$message.success(i18n.t('dialogue.delete_key_succ', { key: row.v }))
|
||||
$message.success(i18n.t('dialogue.delete.success', { key: row.v }))
|
||||
} else {
|
||||
$message.error(msg)
|
||||
}
|
||||
|
@ -280,6 +298,15 @@ const entries = computed(() => {
|
|||
return `${len} / ${Math.max(len, props.length)}`
|
||||
})
|
||||
|
||||
const loadProgress = computed(() => {
|
||||
const len = size(props.value)
|
||||
return (len * 100) / Math.max(len, props.length)
|
||||
})
|
||||
|
||||
const showMemoryUsage = computed(() => {
|
||||
return !isNaN(props.size) && props.size > 0
|
||||
})
|
||||
|
||||
const onAddRow = () => {
|
||||
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.ZSET)
|
||||
}
|
||||
|
@ -312,26 +339,24 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div class="content-wrapper flex-box-v">
|
||||
<content-toolbar
|
||||
:db="props.db"
|
||||
:key-code="props.keyCode"
|
||||
:key-path="props.keyPath"
|
||||
:key-type="keyType"
|
||||
:loading="props.loading"
|
||||
:server="props.name"
|
||||
:ttl="ttl"
|
||||
class="value-item-part"
|
||||
@delete="emit('delete')"
|
||||
@reload="emit('reload')"
|
||||
@rename="emit('rename')" />
|
||||
<slot name="toolbar" />
|
||||
<div class="tb2 value-item-part flex-box-h">
|
||||
<div class="flex-box-h">
|
||||
<div class="flex-box-h" style="max-width: 50%">
|
||||
<content-search-input
|
||||
ref="searchInputRef"
|
||||
@filter-changed="onFilterInput"
|
||||
@match-changed="onMatchInput" />
|
||||
</div>
|
||||
<div class="flex-item-expand"></div>
|
||||
<switch-button
|
||||
:icons="[AlignCenter, AlignLeft]"
|
||||
:stroke-width="3.5"
|
||||
:t-tooltips="['interface.text_align_center', 'interface.text_align_left']"
|
||||
:value="props.textAlign"
|
||||
size="medium"
|
||||
unselect-stroke-width="3"
|
||||
@update:value="(val) => emit('update:textAlign', val)" />
|
||||
<n-divider vertical />
|
||||
<n-button-group>
|
||||
<icon-button
|
||||
:disabled="props.end || props.loading"
|
||||
|
@ -355,6 +380,16 @@ defineExpose({
|
|||
{{ $t('interface.add_row') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- loaded progress -->
|
||||
<n-progress
|
||||
:border-radius="0"
|
||||
:color="props.end ? '#0000' : themeVars.primaryColor"
|
||||
:height="2"
|
||||
:percentage="loadProgress"
|
||||
:processing="props.loading"
|
||||
:show-indicator="false"
|
||||
status="success"
|
||||
type="line" />
|
||||
<div class="value-wrapper value-item-part flex-box-h flex-item-expand">
|
||||
<!-- table -->
|
||||
<n-data-table
|
||||
|
@ -380,11 +415,13 @@ defineExpose({
|
|||
class="entry-editor-container flex-item-expand"
|
||||
style="width: 100%">
|
||||
<content-entry-editor
|
||||
v-model:decode="currentEditRow.decode"
|
||||
v-model:format="currentEditRow.format"
|
||||
v-model:fullscreen="fullEdit"
|
||||
:decode="currentEditRow.decode"
|
||||
:field="currentEditRow.score"
|
||||
:field-label="$t('common.score')"
|
||||
:format="currentEditRow.format"
|
||||
:key-path="props.keyPath"
|
||||
:show="inEdit"
|
||||
:value="currentEditRow.value"
|
||||
:value-label="$t('common.value')"
|
||||
class="flex-item-expand"
|
||||
|
@ -395,8 +432,8 @@ defineExpose({
|
|||
</div>
|
||||
<div class="value-footer flex-box-h">
|
||||
<n-text v-if="!isNaN(props.length)">{{ $t('interface.entries') }}: {{ entries }}</n-text>
|
||||
<n-divider v-if="!isNaN(props.length)" vertical />
|
||||
<n-text v-if="!isNaN(props.size)">{{ $t('interface.memory_usage') }}: {{ bytes(props.size) }}</n-text>
|
||||
<n-divider v-if="showMemoryUsage" vertical />
|
||||
<n-text v-if="showMemoryUsage">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>
|
||||
<div class="flex-item-expand"></div>
|
||||
<format-selector
|
||||
v-show="!inEdit"
|
||||
|
|
|
@ -3,7 +3,10 @@ import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
|
|||
import Code from '@/components/icons/Code.vue'
|
||||
import Conversion from '@/components/icons/Conversion.vue'
|
||||
import DropdownSelector from '@/components/common/DropdownSelector.vue'
|
||||
import { some } from 'lodash'
|
||||
import { includes, isEmpty, map, pull, some, values } from 'lodash'
|
||||
import { computed } from 'vue'
|
||||
import usePreferencesStore from 'stores/preferences.js'
|
||||
import useDialogStore from 'stores/dialog.js'
|
||||
|
||||
const props = defineProps({
|
||||
decode: {
|
||||
|
@ -17,13 +20,52 @@ const props = defineProps({
|
|||
disabled: Boolean,
|
||||
})
|
||||
|
||||
const prefStore = usePreferencesStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const formatTypeOption = computed(() => {
|
||||
return map(formatTypes, (t) => t)
|
||||
})
|
||||
|
||||
const decodeTypeOption = computed(() => {
|
||||
const buildinTypes = [decodeTypes.NONE],
|
||||
customTypes = []
|
||||
const typs = values(decodeTypes)
|
||||
// build-in decoder
|
||||
for (const typ of typs) {
|
||||
if (includes(prefStore.buildInDecoder, typ)) {
|
||||
buildinTypes.push(typ)
|
||||
}
|
||||
}
|
||||
// custom decoder
|
||||
if (!isEmpty(prefStore.decoder)) {
|
||||
for (const decoder of prefStore.decoder) {
|
||||
// replace build-in decoder if name conflicted
|
||||
pull(buildinTypes, decoder.name)
|
||||
customTypes.push(decoder.name)
|
||||
}
|
||||
}
|
||||
return [buildinTypes, customTypes]
|
||||
})
|
||||
|
||||
const decodeMenuOption = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 'new_rdm_decoder',
|
||||
label: 'interface.custom_decoder',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const emit = defineEmits(['formatChanged', 'update:decode', 'update:format'])
|
||||
const onFormatChanged = (selDecode, selFormat) => {
|
||||
if (!some(decodeTypes, (val) => val === selDecode)) {
|
||||
const [buildin, external] = decodeTypeOption.value
|
||||
if (!some([...buildin, ...external], (val) => val === selDecode)) {
|
||||
selDecode = decodeTypes.NONE
|
||||
}
|
||||
if (!some(formatTypes, (val) => val === selFormat)) {
|
||||
selFormat = formatTypes.RAW
|
||||
// set to auto chose format
|
||||
selFormat = ''
|
||||
}
|
||||
emit('formatChanged', selDecode, selFormat)
|
||||
if (selDecode !== props.decode) {
|
||||
|
@ -33,6 +75,14 @@ const onFormatChanged = (selDecode, selFormat) => {
|
|||
emit('update:format', selFormat)
|
||||
}
|
||||
}
|
||||
|
||||
const onDecodeMenu = (key) => {
|
||||
switch (key) {
|
||||
case 'new_rdm_decoder':
|
||||
dialogStore.openPreferencesDialog('decoder')
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -41,19 +91,21 @@ const onFormatChanged = (selDecode, selFormat) => {
|
|||
:default="formatTypes.RAW"
|
||||
:disabled="props.disabled"
|
||||
:icon="Code"
|
||||
:options="formatTypes"
|
||||
:options="formatTypeOption"
|
||||
:tooltip="$t('interface.view_as')"
|
||||
:value="props.format"
|
||||
:value="props.format || formatTypes.RAW"
|
||||
@update:value="(f) => onFormatChanged(props.decode, f)" />
|
||||
<n-divider vertical />
|
||||
<dropdown-selector
|
||||
:default="decodeTypes.NONE"
|
||||
:disabled="props.disabled"
|
||||
:icon="Conversion"
|
||||
:options="decodeTypes"
|
||||
:menu-option="decodeMenuOption"
|
||||
:options="decodeTypeOption"
|
||||
:tooltip="$t('interface.decode_with')"
|
||||
:value="props.decode"
|
||||
@update:value="(d) => onFormatChanged(d, props.format)" />
|
||||
:value="props.decode || decodeTypes.NONE"
|
||||
@menu="onDecodeMenu"
|
||||
@update:value="(d) => onFormatChanged(d, '')" />
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ const onOpenWebsite = () => {
|
|||
<n-text class="about-link" @click="onOpenWebsite">{{ $t('dialogue.about.website') }}</n-text>
|
||||
</n-space>
|
||||
<div :style="{ color: themeVars.textColor3 }" class="about-copyright">
|
||||
Copyright © 2023 Tinycraft.cc All rights reserved
|
||||
Copyright © 2024 Tinycraft.cc All rights reserved
|
||||
</div>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue