Compare commits

...

502 Commits
v1.0.3 ... main

Author SHA1 Message Date
Lykin 3860415429 chore: update dependencies 2025-03-14 17:17:30 +08:00
Lykin dec906a69b chore: update dependencies 2025-03-14 17:13:53 +08:00
Lykin 118b9b8870 chore: upgrade wails to the latest version 2025-02-15 18:43:23 +08:00
Lykin 864bd74029 chore: update release-windows action 2025-01-24 19:16:33 +08:00
Lykin aedf4d4222 chore: update release-windows action 2025-01-24 18:21:00 +08:00
Lykin a4d7c598f9 chore: update release-windows action 2025-01-24 18:12:43 +08:00
Lykin 480e4cc7fd chore: update release-windows action 2025-01-24 18:04:15 +08:00
Lykin 1016df4a25 chore: update dependencies 2025-01-24 15:39:37 +08:00
Lykin 46d7ba2a9f fix: correct text translation 2025-01-16 20:03:40 +08:00
Lykin 19c8368dff fix: add default value to format selector 2025-01-08 16:44:28 +08:00
Lykin 1c322fdac5 pref: correct display raw value for list/set/zset 2025-01-08 16:19:30 +08:00
Lykin 53e8c26380 fix: entry editor support byte array 2025-01-08 16:06:08 +08:00
Lykin 400a908cf9 fix: can not display hash value with unreadable char 2025-01-08 11:55:12 +08:00
Lykin edd77182a5 fix: can not switch to an open connection from connection tree view 2025-01-07 16:24:12 +08:00
Lykin 6e44c64441 chore: update dependencies 2025-01-07 16:20:22 +08:00
Lykin 51beb7249f fix: reset sub tab to `status` page 2024-12-25 11:04:36 +08:00
Lykin 79fd2a6d39 chore: update dependencies 2024-12-19 15:13:33 +08:00
todoList 30c3decd65
fix: incorrect function call argument 2024-12-19 15:11:35 +08:00
Lykin 23fc32e92f chore: update dependencies 2024-12-16 18:43:01 +08:00
Lykin f458a1a0e4 chore: update dependencies 2024-12-16 17:57:38 +08:00
Lykin 52aaad6339 pref: change new version checking and add sponsor ad 2024-12-16 17:57:28 +08:00
Lykin dd70d6b595 chore: update dependencies 2024-12-05 17:14:42 +08:00
Lykin 5d425aadb1 fix: close connection when monitor stopped 2024-12-05 17:12:58 +08:00
Lykin c02a24ee94 chore: compatible with new sass 2024-11-07 14:58:44 +08:00
Lykin e03fc8ad28 chore: update dependencies 2024-11-07 14:57:19 +08:00
Lykin 3367f13d80 fix: incorrect update index in list when filter on 2024-11-06 16:38:52 +08:00
Lykin b601ba255b pref: place the tooltip of switch bottom place at the bottom by default 2024-11-06 16:17:29 +08:00
Lykin ccb4bb85ae fix: type mismatch when encoding to msgpack (#380) 2024-11-04 14:26:17 +08:00
Lykin b0dfe348bd Merge remote-tracking branch 'origin/main' 2024-10-18 16:37:56 +08:00
Lykin a3a1a17af3 fix: force switch to key detail tab when `Auto Refresh` on 2024-10-18 16:37:03 +08:00
Joanne Tweed ca9f0a08e1
fix: reset `ReadTimeout`&`ReadTimeout` for sentinel mode through ssh 2024-10-14 16:20:49 +08:00
Lykin 3f5b63a36f revert: revert `wails` to `2.9.1` 2024-09-29 22:40:30 +08:00
Lykin ab3560fc2b chore: update dependencies 2024-09-28 15:31:52 +08:00
Lykin cb428747e2 fix: key deletion operation was incomplete (#348) 2024-09-28 11:55:38 +08:00
Lykin aa3383db43 chore: update dependencies 2024-09-25 14:54:49 +08:00
Lykin 026591c8d4 fix: no db loaded (#353) 2024-09-25 14:40:43 +08:00
Lykin a70b5b56ff pref: compatible with IPv6 address 2024-09-06 15:08:53 +08:00
Lykin eaa68df583 fix: can not remove argument from custom decoder (#347) 2024-09-03 14:34:40 +08:00
Lykin 2388f309d8 fix: parse cmd error with single quotes 2024-09-02 18:50:51 +08:00
Lykin a9c7cb1cd2 chore: trim svg icon 2024-08-31 23:54:18 +08:00
Lykin b506e8a6a4 fix: auto refresh should not force activation of owning tabs (#336) 2024-08-20 14:57:51 +08:00
Lykin c082a0c41f pref: does not close dialog when adding data error (#338) 2024-08-20 14:29:25 +08:00
Ikko Eltociear Ashimine 970ebcf902
docs: add Japanese README (#335) 2024-08-18 11:50:15 +08:00
Lykin b223feb441 chore: update the minimum compatible version for macOS 2024-08-16 23:39:59 +08:00
Lykin 469a62333f fix: error occurred when add SET key with the same name twice (#327) 2024-08-16 23:38:38 +08:00
Lykin c38944e948 fix: stop command monitor may block ui (#326) 2024-08-15 12:33:51 +08:00
Lykin 5efbd4d316 chore: update dependencies 2024-08-07 23:17:50 +08:00
Lykin c54567115e pref: remove transparent background on Windows 2024-08-07 23:12:01 +08:00
Lykin a14e7e947e fix: compatible when the `INFO` command is unavailable 2024-08-07 11:25:54 +08:00
Lykin 868b0c81b6 fix: compatible with non-macOS when the shortcut key contains `metaKey` 2024-08-07 10:14:59 +08:00
Lykin a3cb09863a fix: remove tabindex of div due to black window edge 2024-08-06 23:11:14 +08:00
Lykin b26f5d2bde fix: sync env config (#319) 2024-08-06 18:12:21 +08:00
Lykin 0038092193 fix: add window shadow on Windows 2024-07-31 23:25:14 +08:00
Lykin 0739cb8b68 feat: add `LZ4` encoder/decoder 2024-07-31 16:49:52 +08:00
Lykin 70c38d9aa7 fix: can not display correct content with decoder after modified 2024-07-31 16:46:50 +08:00
Lykin 237b25086c chore: update dependencies 2024-07-27 22:27:33 +08:00
Lykin 71dbda01da pref: support text alignment config for HASH/LIST/SET/ZSET (#264) 2024-07-27 22:20:27 +08:00
Lykin 5deb6ce443 pref: support text alignment config for HASH/LIST/SET/ZSET (#264) 2024-07-26 17:19:28 +08:00
Lykin ee398d4d98 pref: support `Home` and `End` input for cli (#313) 2024-07-26 11:09:13 +08:00
Lykin 29b51f836f pref: replace linear css animation due to it takes a lot of CPU 2024-07-24 11:30:06 +08:00
Lykin ea8ceba32a fix: reconnect will crash after connection fail first time (#301) 2024-07-11 16:03:13 +08:00
Lykin 9bec3934bb chore: update dependencies 2024-07-04 10:03:41 +08:00
Lykin fdfd04d4bf pref: add a tooltip if the type filter is not supported (#274) 2024-07-03 22:34:07 +08:00
Lykin 410dcd9e57 fix: compatible with Sogou input (#277) 2024-07-02 11:37:19 +08:00
Lykin ea44253c02 fix: disable type filter when redis server below 5.x (#274) 2024-07-01 17:35:32 +08:00
Lykin e2093a89bf fix: the contents inside `pre` tag will not be wrapped (#263) 2024-06-28 16:23:51 +08:00
Lykin 908d4c7007 chore: update dependencies 2024-06-28 14:33:55 +08:00
Lykin 6843314bad perf: support refresh by shortcut `cmd/ctrl+r` on non-macOS platform 2024-06-21 15:00:53 +08:00
Lykin bdfa31e4b6 fix: copy text not work for non-ascii text (#246) 2024-06-19 14:39:37 +08:00
Lykin aa8c5495c1 fix: database filter option can not display 2024-06-19 10:36:57 +08:00
Lykin 65cfdd1bcc perf: replace some slice utils with built-in utils 2024-06-19 10:30:13 +08:00
Lykin 6bd1b23a64 fix: unable to connect to any server after manually interrupt 2024-06-18 18:04:00 +08:00
Lykin 8c30daec15 chore: update dependencies 2024-06-17 18:37:14 +08:00
Lykin 86f42fcc10 perf: support batch delete keys without scan confirm (#283) 2024-06-17 18:29:56 +08:00
Lykin 1bcde26e35 perf: remove tooltip alive when mouse over the icon button (#284) 2024-06-17 10:20:03 +08:00
Lykin bf71c6db0e perf: trim spaces of connection address(#281) 2024-06-11 11:47:52 +08:00
Lykin 88e2c6cb43 fix: incorrect binary value convert (#279) 2024-06-05 10:46:38 +08:00
Lykin 6eeb701439 fix: the overflow tab cannot be fully display 2024-06-04 10:43:49 +08:00
Lykin 028a240f49 perf: evenly divide the number of scans for each cluster node 2024-06-04 10:34:42 +08:00
Lykin eefa7b1346 style: change chart time to 24-hour format 2024-05-27 16:52:20 +08:00
Lykin 3321fbf6fd style: fix the width and height of the redis type tag 2024-05-27 16:05:13 +08:00
Lykin 4ed93902a6 fix: null decoder args (#266) 2024-05-24 16:36:18 +08:00
Lykin b4405eb7db chore: update dependencies 2024-05-22 23:05:41 +08:00
Lykin 04bc103583 fix: line numbers in the monaco editor may wrapped 2024-05-22 22:43:54 +08:00
Lykin f17bb744f4 perf: support refresh field in hash (#260) 2024-05-22 17:24:38 +08:00
Lykin 152fbe962f chore: add bilibili official account 2024-05-22 00:58:30 +08:00
Lykin a2b0fc183f fix: new key to a non-current database incorrectly refresh the tree view (#259) 2024-05-21 15:55:02 +08:00
Lykin f536b0f23b revert: remove `Cmd+D` to do any delete action 2024-05-20 00:47:50 +08:00
Lykin 3c43f960c3 perf: support `F5`/`Cmd+R` to refresh selected key in key tree view 2024-05-20 00:44:55 +08:00
Lykin 78bfaf6e16 perf: support `Ctrl+C`/`Cmd+C` to copy selected key name 2024-05-20 00:30:15 +08:00
Lykin 50bec33870 perf: use `Delete` on key tree view compatible with redis key prefix 2024-05-19 23:50:22 +08:00
Lykin f0c9b74545 perf: support close tab by shortcut `Command+W`/`Control+W` (#258) 2024-05-18 23:23:48 +08:00
Lykin e5fed29427 perf: json format compatible with nonstandard value 2024-05-17 16:25:40 +08:00
Lykin 4dd52a8c8e perf: reduce warnings caused by network connection failures (#252) 2024-05-17 14:43:43 +08:00
Lykin abf5534165 perf: re-refresh with re-click the activated key after more then 1 sec (#242) 2024-05-15 17:37:24 +08:00
Lykin 455a911154 perf: support keyboard navigation in key tree view (#238) 2024-05-14 10:09:50 +08:00
Lykin e2264b33b0 fix: expanded mark may not update 2024-05-11 17:56:44 +08:00
Lykin 84b493b26a chore: update dependencies 2024-05-11 10:42:25 +08:00
Lykin c9e618d418 perf: remove padding top and bottom of tree item (#255) 2024-05-11 10:37:31 +08:00
Lykin e8f76ce8ae fix: remove running `pickle` checking script on macOS (#201) 2024-05-09 11:21:20 +08:00
Lykin 70354c14ec perf: disable auto decode for list/hash/set/zset (#175 #250) 2024-05-06 11:26:17 +08:00
Lykin d472836d5f fix: styling discrepancies on macOS 11.x below (#245) 2024-04-30 17:13:32 +08:00
Lykin f00a959db3 fix: the version tag was incorrectly written 2024-04-29 12:06:34 +08:00
Lykin aa98da31d6 revert: monaco editor may not open on macOS 11.x (#241) 2024-04-29 11:30:25 +08:00
Lykin 18ba04a5b1 perf: rename empty layer to `NO NAME` 2024-04-29 11:28:49 +08:00
Lykin c9beceab76 revert: remove disable window zoom 2024-04-27 22:33:24 +08:00
Lykin 6abf4823a6 perf: improve key filter tips 2024-04-26 18:20:54 +08:00
Lykin 2133fe44ca fix: the count of failed deletion is incorrect in cluster mode 2024-04-26 16:50:43 +08:00
Lykin 8139481ea7 fix: fail to open pub/sub in cluster mode #239 2024-04-26 16:21:14 +08:00
Lykin d41421389c chore: update dependencies 2024-04-26 15:30:52 +08:00
Lykin 1cf126e78d doc: update README 2024-04-26 15:29:20 +08:00
Lykin 4d29343c1f style: make the empty prompt in the tree view vertically 2024-04-22 16:07:57 +08:00
Lykin a682aabb0b fix: can not refresh value display after edit 2024-04-19 17:21:20 +08:00
Lykin 971c89a5cf perf: add shortcut for key operations 2024-04-19 16:43:42 +08:00
Lykin b72855b707 perf: add shortcut `esc` for close modal dialog (#235) 2024-04-19 15:13:06 +08:00
Lykin 974477cb49 fix: maximum call stack size exceeded (#234) 2024-04-19 11:54:05 +08:00
Lykin 1bf4b0eee1 fix: can not load the same key after delete and re-added (#226) 2024-04-18 20:17:10 +08:00
Lykin 33caf873d6 fix: parse command error (#231) 2024-04-17 18:34:49 +08:00
Cloud fc0deac074
fix: add linux dependency (#223) 2024-04-11 10:41:54 +08:00
Lykin eb5f69bcbc chore: update dependencies 2024-04-09 10:46:32 +08:00
Lykin 28c326d608 fix: keep `network` when save connection failed 2024-04-08 15:25:10 +08:00
Lykin 83319c06d7 fix: incorrect placeholder of input for sentinel 2024-04-08 10:34:56 +08:00
Lykin dbe3d995b4 chore: update text 2024-04-03 17:34:40 +08:00
Lykin 8ab82534e1 fix: search input box length overflow (#217) 2024-04-03 14:58:54 +08:00
Lykin fc67590bde fix: keep the app title unwrapped (#217) 2024-04-02 18:31:09 +08:00
Lykin 2e28c1b44b fix: context menu in the browser tree 2024-04-02 18:00:16 +08:00
Lykin c7c647d728 perf: use `exec.LookPath` to check the path of external application 2024-04-02 17:26:23 +08:00
Lykin 4fd73411de revert: update text description 2024-04-02 17:24:06 +08:00
Lykin b8e1c2fca4 fix: commands that contain escape characters cannot be executed on the command line (#213) 2024-04-01 17:14:39 +08:00
Lykin cda10ed279 doc: add custom decoding guide to README 2024-04-01 10:08:49 +08:00
Lykin 2e620f7050 chore: update text description 2024-03-27 15:58:32 +08:00
Lykin c0fdfc0ce7 fix: limits the minimum number of slow logs to be displayed 2024-03-26 17:53:24 +08:00
Lykin d1958a3290 perf: view type automatic synchronization between edit and list for complex type 2024-03-26 17:33:30 +08:00
Lykin 668620425f chore: disable windows translucent on macOS (#200) 2024-03-26 11:05:16 +08:00
Lykin cc696f9642 perf: clear key filter after switch database 2024-03-25 17:42:19 +08:00
Lykin adf9e4347f chore: update button description (#196) 2024-03-25 16:22:51 +08:00
Lykin 41d0dc6ffc perf: keys containing numbers are sorted by size first (#198) 2024-03-25 16:22:51 +08:00
Lykin d99a397ae3 revert: linux packaging environment 2024-03-21 11:27:37 +08:00
Lykin 1091f5d34c chore: update workflows 2024-03-19 23:44:36 +08:00
Lykin 35c6918b9b chore: update workflows 2024-03-19 23:29:27 +08:00
Lykin 006f3b2a05 chore: update workflows 2024-03-19 23:20:05 +08:00
Lykin d2e3392283 chore: update dependencies 2024-03-19 22:12:27 +08:00
Lykin 3900052adb chore: remove chart animations to reduce performance costs (#187) (#188) 2024-03-18 20:10:22 +08:00
Lykin bb9c25a880 perf: disable tracking in development environment 2024-03-15 16:31:39 +08:00
Lykin af6748ac59 chore: update README 2024-03-15 16:30:40 +08:00
Lykin 66715d05a5 chore: add Korean support 2024-03-14 18:36:16 +08:00
Lykin 656e18afe5 chore: add `Privacy Policy` link (#181) 2024-03-14 18:36:16 +08:00
Lykin eca6bd523e perf: reduces page flicker during refresh 2024-03-14 17:12:46 +08:00
Lykin 13b9a38095 chore: add Russian support 2024-03-14 15:27:42 +08:00
Lykin de7cdb5bd4 perf: add behavior tracking instructions and switch options (#181) 2024-03-14 15:27:42 +08:00
Lykin e3ccb06a96 chore: update the year to 2024 2024-03-14 00:38:35 +08:00
Lykin 8e48da3cc7 chore: add Japanese, Korean, French and Spanish support 2024-03-11 18:29:11 +08:00
Lykin 635cd6ca8b chore: update dependencies 2024-03-09 23:07:40 +08:00
Lykin aa291f742d perf: change the trigger of the "more action" button to click 2024-03-09 23:04:36 +08:00
Lykin 2d2954d81c feat: support exact match filter (#164) 2024-03-09 23:04:36 +08:00
Lykin fdf2c477f2 feat: add traditional Chinese 2024-03-08 09:52:21 +08:00
Lykin f3c5065259 chore: adjust Portuguese translation 2024-03-07 11:48:01 +08:00
Lykin 78f3abaf5e chore: adjust English translation 2024-03-07 11:41:02 +08:00
Lykin 6430deeaea chore: add entrance about user guide and custom decoder 2024-03-07 00:35:09 +08:00
Lykin a13bf788eb feat: add last format and decode selection record for keys 2024-03-05 11:49:47 +08:00
raojinlin b745b9f45d
feat: add linux AppImage build support (#168)
---------

Co-authored-by: raojinlin <1239015423@qq.com>
Co-authored-by: raojinlin <raojinlin302@gmail.com>
2024-03-01 10:15:15 +08:00
Lykin b46cc6c436 fix: update value stored in tabStore after modified and saved 2024-02-28 18:27:39 +08:00
Lykin 00b1efc43d fix: incorrect content saving when `DECODE_NONE` selected 2024-02-28 18:21:31 +08:00
Lykin f8ef25a323 fix: add background color for empty client list table 2024-02-27 17:20:18 +08:00
Lykin bb4ab109e7 fix: keep key sorting under "Unicode JSON" format #158 2024-02-27 14:50:19 +08:00
Lykin 3a799b7b4a fix: keep key sorting under "Unicode JSON" format #158 2024-02-27 11:54:14 +08:00
Lykin 71ffbde648 perf: remove 'virtual scroll' in slow log table #161 2024-02-26 15:01:13 +08:00
raojinlin 9bd958ef24
style: new key type selector align center on windows #166
Co-authored-by: raojinlin <raojinlin302@gmail.com>
2024-02-26 12:55:51 +08:00
Lykin ad2c4c432b perf: value editor maintains scroll offset after refresh #162 2024-02-26 11:38:12 +08:00
raojinlin c4d41b12dc
feat: value editor maintains scroll offset after refresh #162
* feat: refresh string value keep scrolltop

* fix code styles

* delete unused code

* feat: Configurable and compatible with keyPath changes

* Fix props name format, use kebab-case

* Unify coding style

---------

Co-authored-by: raojinlin <raojinlin302@gmail.com>
2024-02-26 10:34:21 +08:00
Lykin 53563acac0 chore: add manual trigger to github action 2024-02-26 10:28:26 +08:00
Lykin 23b68d9e19 fix: can not build Windows installer #163 2024-02-23 23:59:37 +08:00
Lykin 9a10aba67e fix: can not build Windows installer #163 2024-02-23 22:38:35 +08:00
Lykin a431467b5f chore: remove -upx flag due to windows/arm cause error 2024-02-22 23:08:31 +08:00
Lykin 78b7700b1b fix: github action for signing windows package 2024-02-22 22:47:07 +08:00
Lykin 28d2336124 fix: github action for signing windows package 2024-02-22 22:12:23 +08:00
Lykin 2250e15971 chore: update README 2024-02-22 21:32:42 +08:00
Lykin 70ed556e80 chore: update dependencies 2024-02-22 18:31:14 +08:00
Lykin 13e80da978 feat: support HTTP/SOCKS5 proxy for connections #159 2024-02-22 18:31:14 +08:00
Lykin 64ae79f565 perf: add `New Custom Decoder` entrance to decoder dropdown list 2024-02-22 10:42:55 +08:00
Lykin cb9a9ebb8a fix: slashes should not be escaped in PHP decoder 2024-02-22 00:22:29 +08:00
Lykin 434568e66d perf: support build windows/arm64 2024-02-22 00:10:48 +08:00
Lykin 7cf842ed4c fix: incorrect radio button display 2024-02-21 22:23:49 +08:00
Lykin 3057012710 feat: add "Unicode JSON" format to display JSON string contains unescaped unicode charactor #158 2024-02-21 15:08:29 +08:00
Lykin 1c4462b161 perf: use x icon instead of text 2024-02-21 10:17:48 +08:00
Lykin 30835ac469 perf: hide window on windows when run external command for decode 2024-02-20 23:27:31 +08:00
Lykin 2405a79ace feat: add PHP Serialized/Pickle as build-in decoder. #64 #87 2024-02-20 10:55:46 +08:00
Lykin e92eb525e7 feat: support value type ReJSON #152 2024-02-19 00:40:38 +08:00
Lykin 094705e87d fix: can not switch db in sentinel mode #144 2024-02-18 22:28:50 +08:00
Lykin f1e1a89baf perf: add links option for value editor #143 2024-02-18 21:46:00 +08:00
Lykin 29ffd83486 fix: can not open external url in editor #143 2024-02-18 21:35:06 +08:00
Lykin df71ac7049 perf: omit config file keys for different connection networks 2024-02-18 14:54:46 +08:00
Lykin 32f1b71073 refactor: standardize key names in YAML config file 2024-02-18 14:53:00 +08:00
Lykin 74a6b9b0e1 perf: optimize conflict connection name when duplicating 2024-02-18 14:32:13 +08:00
Lykin 09264134ec perf: support unix-socket via url string #142 2024-02-18 14:27:21 +08:00
Lykin a2331675d7 feat: support connect by unix-socket #142 2024-02-18 14:17:06 +08:00
Lykin e1f022908c perf: support double click to modify match keyword #149 2024-02-14 14:44:54 +08:00
Lykin c0415fe23d fix: support zset score +inf/-inf #148 2024-02-14 13:54:09 +08:00
Lykin e271eafc9e chore: update dependencies 2024-02-09 16:24:02 +08:00
Lykin a8109c4bb2 fix: remove delimiter completion during key match #147 2024-02-03 15:58:13 +08:00
Lykin ce1b9b706f perf: add drag and drop text option in preferences. #140 2024-02-03 15:40:23 +08:00
Lykin 450e451781 feat: add custom decoder/encoder for value content 2024-02-03 15:40:23 +08:00
Lykin 7faca878a3 perf: add auto format detect for `yaml` and `xml` 2024-01-30 01:30:44 +08:00
Lykin 74d789ac8e chore: update placeholder of font select input 2024-01-29 11:21:24 +08:00
Lykin fd58357a04 fix: incorrect formatted data when save with msgpack 2024-01-27 16:24:45 +08:00
Lykin f09ee89a96 refactor: split data convert into separate files 2024-01-27 16:24:45 +08:00
Lykin 7a579d0d0b feat: add decoder `msgpack` #134 2024-01-27 01:57:29 +08:00
Lykin f94134faa0 perf: support redis url with `rediss` protocol #127 2024-01-26 12:56:50 +08:00
Lykin 581a1b79ca chore: update dependencies 2024-01-26 00:07:47 +08:00
Lykin b361e9b0be chore: integrate with umami 2024-01-26 00:07:25 +08:00
Lykin 4032c80add feat: support create connection by redis url string #127 2024-01-25 17:48:27 +08:00
Lykin f3a43c8083 fix: block ctrl key combinations input in cli 2024-01-25 15:34:43 +08:00
Lykin b610bd7861 fix: incorrect ttl display 2024-01-25 14:50:03 +08:00
Lykin 5a86bab647 feat: add connected client list 2024-01-25 00:29:15 +08:00
Lykin fe2f8a0480 chore: update go dependencies 2024-01-24 20:02:14 +08:00
Lykin 7cbdc83884 perf: ttl change to dynamic display 2024-01-24 14:53:04 +08:00
Lykin 124d221b9f perf: hide context menu in tree view when press 'esc' 2024-01-24 11:25:59 +08:00
Lykin edaef2a78c feat: add cursor style option for cli 2024-01-23 20:55:38 +08:00
Lykin d75635bf70 feat: add cli preferences #131 2024-01-23 20:39:45 +08:00
Lykin 0b37b89f9b perf: support select multiple font and input custom font name #115 #130 2024-01-23 20:12:52 +08:00
Lykin cdac3c4496 perf: significantly improve batch deletion performance with pipeline #123 2024-01-23 17:37:00 +08:00
Lykin 655cd539ca perf: add inverse prop on icon 'subscribe' 2024-01-23 14:19:17 +08:00
Lykin 76783c36fb Merge remote-tracking branch 'origin/main' 2024-01-23 11:47:45 +08:00
Lykin 3c7003291c
Merge pull request #129 from kt286/realtime-editor-config
feat: update editor config realtime
2024-01-23 11:46:17 +08:00
Lykin 4cbc0b98e7 fix: 'refreshInterval' may not read the correct value 2024-01-23 11:35:40 +08:00
Lykin a679858478 perf: add silent deletion option to batch delete dialog 2024-01-23 11:26:15 +08:00
Cloud 94d642c4de feat: modify editor config realtime 2024-01-22 16:45:30 +08:00
Lykin ff2043c0e2 perf: add batch delete to menu item "more" in browser pane 2024-01-22 11:40:31 +08:00
Lykin c2c1e9cfba perf: disable save button after successful save content 2024-01-21 22:33:45 +08:00
Lykin e3e0ed0a91 fix: connection not release after failure 2024-01-20 17:59:51 +08:00
Lykin 74ab68b280 chore: update vite 2024-01-20 17:07:59 +08:00
Lykin cee25a8015 chore: add x link to ribbon 2024-01-20 17:00:25 +08:00
Lykin bbcbc8b661 feat: support some emacs bash shortcut(ctrl+a/e/f/b/d/h/u/k/w/p/n/l) #126 2024-01-20 16:09:09 +08:00
Lykin 673159dc85 fix: db alias not display in empty database 2024-01-20 11:17:45 +08:00
Lykin 700a54081b fix: block copy action when double click refresh button inside #124 2024-01-20 10:12:58 +08:00
Lykin 9564272fd8 fix: can not change font and font size of editor #125 2024-01-19 16:08:24 +08:00
Lykin a2336b4fc8 fix: block copy action when double click refresh button inside #124 2024-01-19 11:36:47 +08:00
Lykin 6aca08de3e doc: update README 2024-01-19 10:54:02 +08:00
Lykin f0ff3e9ed5 doc: update screenshots 2024-01-19 00:58:53 +08:00
Lykin 47014933bf doc: update README 2024-01-19 00:48:17 +08:00
Lykin b674e291b4 doc: update README 2024-01-19 00:45:59 +08:00
Lykin 15f6314597 perf: add dark theme support for charts 2024-01-19 00:39:09 +08:00
Lykin 06853f7c75 build: update action for build dmg file, add repair bash file inside. 2024-01-18 21:00:02 +08:00
Lykin 8948f76f16 chore: update dependencies 2024-01-18 18:32:23 +08:00
Lykin 6d3526c765 feat: support database alias 2024-01-18 17:28:17 +08:00
Lykin 42fa24debd perf: ttl value will use unit 'day' when above 1 day #122 2024-01-18 15:18:41 +08:00
Lykin 022ee20eed feat: support import/export connection profiles 2024-01-18 14:40:07 +08:00
Lykin 9402af2433 perf: set status auto refresh on by default and save to connection profile 2024-01-18 14:40:07 +08:00
Lykin 6538313da8 fix: label in charts not update when changing language 2024-01-18 01:13:21 +08:00
Lykin 649cc71680 chore: update dependencies 2024-01-17 20:59:08 +08:00
Lykin c76a0a505f feat: support pub/sub 2024-01-17 20:57:28 +08:00
Lykin ffed68ae4c perf: remove unused parameter in content-slog 2024-01-17 16:40:49 +08:00
Lykin 7fecbc2b53 perf: replace bytes format with custom implement 2024-01-17 11:24:10 +08:00
Lykin ee57346df6 feat: add activity monitor 2024-01-16 21:03:43 +08:00
Lykin 70235cc295 fix: incorrect cursor position in cli #120 2024-01-13 19:31:08 +08:00
Lykin 718e89a641 perf: add auto-refresh option for slow log 2024-01-12 16:07:58 +08:00
Lykin 0a26ac6300 perf: adjust scroll parameters 2024-01-12 15:55:51 +08:00
Lykin ba8d19a121 refactor: use 'auto-refresh-form' in value content 2024-01-12 15:45:07 +08:00
Lykin a663ecdeb5 perf: add refresh interval option for server status page 2024-01-12 15:36:30 +08:00
Lykin a3f9c62f4e fix: add default value for auto refresh interval #116 2024-01-12 15:03:28 +08:00
Lykin 4bf35e736e refactor: auto-refresh in server status 2024-01-12 11:38:25 +08:00
Lykin 042ef3075a fix: no data on server status page #119 2024-01-12 11:18:41 +08:00
Lykin cd36dbda48 doc: update README 2024-01-12 00:55:06 +08:00
Lykin 56bc609420 chore: update dependencies 2024-01-12 00:00:32 +08:00
Lykin dbff8f9e79 perf: select key when right click in browser tree 2024-01-11 23:55:03 +08:00
Lykin 1a6756905d fix: add default value for auto refresh interval #116 2024-01-11 22:16:13 +08:00
Lykin aab2531d40 perf: move wechat account entrance to ribbon 2024-01-11 18:37:31 +08:00
Lykin 2e7f832754 chore: update dependencies 2024-01-11 18:25:35 +08:00
Lykin f3f12479fd perf: disable load value when right-click in tree view 2024-01-11 17:45:10 +08:00
Lykin e1362fce45 fix: unable to copy binary key 2024-01-11 14:43:23 +08:00
Lykin f1cfa1778f perf: don not remove key from list view if not exists 2024-01-11 14:30:13 +08:00
Lykin b7ba179e62 perf: compatible with situations where the "memory" command cannot be used #144 2024-01-11 14:30:12 +08:00
Lykin bc53c405a5 chore: update some text 2024-01-11 11:50:10 +08:00
Lykin 5d2080aafb perf: support "skip insecure verify" and "sni" for SSL #67 #113 2024-01-11 00:32:55 +08:00
Lykin d989cdd85b chore: add wechat official account entrance 2024-01-10 18:27:56 +08:00
Lykin 115aa9d079 refactor: move i18n calling out of computed 2024-01-10 15:32:19 +08:00
Lykin 4258a45498 chore: update dependencies 2024-01-10 15:32:19 +08:00
Lykin cefc5a5078 doc: update doc 2024-01-09 00:17:07 +08:00
Lykin 8c69ce7257 perf: replace component 'n-log' with 'n-virtual-list' 2024-01-08 17:38:54 +08:00
Lykin c497423711 revert: naive-ui back to 2.36.0 due to method 'validate' unable to execute completely 2024-01-08 17:36:52 +08:00
Lykin 927678ebbb fix: tooltips exceed the display area #112 2024-01-08 11:29:41 +08:00
Lykin bc66c63b3d chore: update dependencies 2024-01-08 00:26:57 +08:00
Lykin 72ef406cd8 fix: tooltips exceed the display area #112 2024-01-08 00:26:01 +08:00
Lykin 51a1b1b35f perf: disable tab switch after layer node selected 2024-01-08 00:01:52 +08:00
Lykin e87b02e14c perf: add an 'Import Keys' option to new key dialog. 2024-01-06 23:44:18 +08:00
Lykin 0d6765a757 perf: scroll to bottom when append item in new key dialog 2024-01-06 23:08:06 +08:00
Lykin ed1b9d9b54 fix: create key error 2024-01-06 22:13:26 +08:00
Lykin d66d7c9a49 feat: support key auto refresh
refactor: move 'ContentToolbar' to 'ContentValueWrapper'
2024-01-06 17:14:35 +08:00
Lykin c2bf4128f7 fix: minor bugs about keys view 2024-01-06 01:26:18 +08:00
Lykin 8d7c8cb3ed perf: support display layer with blank label 2024-01-05 22:02:23 +08:00
Lykin 9b3f2ba726 perf: move expanded keys to tab store for external modification 2024-01-05 20:48:57 +08:00
Lykin 7f2fac7fe8 chore: standardize parameter naming 2024-01-05 18:24:38 +08:00
Lykin 13dbc9b3b6 perf: support custom ttl when import keys 2024-01-05 17:55:54 +08:00
Lykin 1d1fab54d8 perf: support batch update ttl of keys 2024-01-05 00:48:29 +08:00
Lykin ed18d8b5ee fix: disable nested folders in connections pane 2024-01-05 00:04:28 +08:00
Lykin e071e65701 perf: replace tooltip content when reload is unavailable 2024-01-04 18:17:10 +08:00
Lykin b823f18794 perf: optimize the performance of command monitor 2024-01-04 12:01:36 +08:00
Lykin ac6d68d17d fix: clean selected keys after deleted 2024-01-04 00:44:27 +08:00
Lykin 23087a5374 perf: add binary suffix to binary keys in tree list 2024-01-04 00:33:51 +08:00
Lykin a71f5e0070 perf: support reload option after import data 2024-01-04 00:13:44 +08:00
Lykin 1340f911c8 fix: key count may not update after soft delete key 2024-01-03 18:04:49 +08:00
Lykin 37efe5e72a perf: remove increase/decrease button in some number input 2024-01-03 16:55:41 +08:00
Lykin 36a8c38877 perf: add quick settings for ttl dialog 2024-01-03 15:55:53 +08:00
Lykin 18f1b976c6 refactor: replace some 'watch' with 'watchEffect' 2024-01-03 11:04:40 +08:00
Lykin 84b73bd5e7 fix: root key may not expand after switch to check mode in key tree 2024-01-03 01:08:11 +08:00
Lykin 5e970982a2 style: update style of context menu 2024-01-03 00:32:52 +08:00
Lykin 535fdabb0c doc: update screenshots 2024-01-02 17:16:12 +08:00
Lykin df5a577919 style: adjust button style 2024-01-02 16:14:56 +08:00
Lykin 554b2b9f72 fix: move "import data" to dropdown of add button 2024-01-02 15:43:50 +08:00
Lykin bb676b974a chore: upgrade vue to 3.4.x 2024-01-02 00:35:13 +08:00
Lykin 660fc0cf93 fix: database switching may do nothing 2024-01-02 00:34:48 +08:00
Lykin d2bb2995c5 docs: update doc 2023-12-29 15:48:09 +08:00
Lykin 4dc8839a51 feat: add format type 'YAML' and 'XML' #110 2023-12-29 01:03:40 +08:00
Lykin 4c9d75303c perf: minimize the use of database switch commands 2023-12-29 00:16:25 +08:00
Lykin df3865dc7d fix: switching between databases might trigger errors #105 2023-12-29 00:14:56 +08:00
Lykin 10ec866037 feat: support real-time command monitoring 2023-12-28 17:52:28 +08:00
Lykin 665f8801ca refactor: encapsulate tab state into classes 2023-12-27 20:51:17 +08:00
Lykin 93b04071e2 feat: add a root node for multiple selection and deselection in multi-selection mode 2023-12-27 18:23:40 +08:00
Lykin b7433fadaa fix: clean tree status after flush database 2023-12-27 18:02:35 +08:00
Lykin f597002378 feat: add expire time data for import/export handle 2023-12-27 17:44:10 +08:00
Lykin 3fe8767c44 feat: support import keys from csv file 2023-12-27 17:44:10 +08:00
Lykin 2bc7a57773 chore: update dependencies 2023-12-27 11:22:47 +08:00
Lykin 0fb93258e9 refactor: encapsulate connection state and behavior into classes 2023-12-26 15:40:31 +08:00
Lykin 21a569d9bb perf: optimize the logic for bulk deletion of selected keys 2023-12-26 15:09:52 +08:00
Lykin e28bb57a25 fix: the deletion result might be incorrect 2023-12-26 01:15:49 +08:00
Lykin 5b9f261824 refactor: encapsulate connection state and behavior into classes 2023-12-26 01:15:49 +08:00
Lykin 7fa1ecfa0a refactor: remove unused "keySet" in browserStore 2023-12-19 20:19:41 +08:00
Lykin f98229b9fa refactor: refactor the method for delete selected keys 2023-12-19 20:10:01 +08:00
Lykin bce4e2323e feat: add export capability for selected keys 2023-12-18 01:01:51 +08:00
Lykin b06217adc0 perf: add bug report entrance 2023-12-16 12:43:58 +08:00
Lykin f5611a2635 fix: tooltip too long to show 2023-12-16 01:45:28 +08:00
Lykin c5abaa6573 chore: change the timing for saving window position changes. 2023-12-16 01:21:30 +08:00
Lykin 8f15656f37 fix: turn off spellcheck on macOS #98 2023-12-16 00:33:35 +08:00
Lykin 860e1eaac3 fix: error cause when switch type in new key dialog 2023-12-16 00:33:35 +08:00
Lykin d31da4a055 perf: refresh key summary after crud value content 2023-12-16 00:33:35 +08:00
Lykin 566a7e212f perf: add line break to command display 2023-12-14 10:47:10 +08:00
Lykin c7a365e8e9 fix: error caused when execute lua script in cli #103 2023-12-13 12:22:41 +08:00
Lykin 08c42ca85c refactor: move checked keys mark from tree view to tab store
fix: deleting message may not disappear #102

fix: some error in check mode
2023-12-13 11:48:27 +08:00
Lykin f7f394972d fix: select incorrect database when double click key layer #99 2023-12-12 16:15:30 +08:00
Lykin 94c7b7ade5 perf: save windows position and restore for next launch #101 2023-12-12 11:40:22 +08:00
Lykin 44770a4a8e feat: add code folding toggle to preferences 2023-12-12 10:35:29 +08:00
Lykin d819502bc7 chore: update dependencies 2023-12-11 18:33:31 +08:00
Lykin e942f41f66 fix: monaco editor can not adjust height when parent node's size changes from large to small 2023-12-11 18:32:16 +08:00
Lykin ac76131f18 fix: turn off spellcheck on macOS #99 2023-12-08 18:22:37 +08:00
Lykin e0d5e0c874 chore: update dependencies 2023-12-08 15:56:06 +08:00
Lykin f966fec0a3 perf: add code folding and shortcut for save #97 2023-12-08 10:38:08 +08:00
Lykin 86b3ac0bd4 doc: update README and screenshots 2023-12-07 11:34:49 +08:00
Lykin 5dd49a78dd perf: add loading status to "redis type tag" 2023-12-07 11:11:31 +08:00
Lykin 116513917d fix: renaming keys may not refresh the browser view 2023-12-07 10:39:50 +08:00
Lykin d4d1c33cb3 feat: add draggable feature to ribbon menu 2023-12-06 18:52:33 +08:00
Lykin d59221f11e fix: the database switch might lead to other operations becoming ineffective 2023-12-06 18:25:51 +08:00
Lykin b7c10b33e7 chore: update dependencies 2023-12-06 11:22:04 +08:00
Lykin 2f25b524cd perf: add 'use-glob' prop to search input 2023-12-06 11:10:54 +08:00
Lykin 84b42e6461 perf: optimize type match selector 2023-12-06 10:55:28 +08:00
Lykin f9abba37d2 fix: entry editor may lose field value 2023-12-05 16:35:08 +08:00
Lykin 827ca1f2e7 feat: support more key icon display type 2023-12-05 15:58:10 +08:00
Lykin ba55cbcbd3 fix: saving of the last opened database is ineffective 2023-12-04 18:54:06 +08:00
Lykin 686f73c3dd perf: reduced the frequency of invoking SELECT command 2023-12-04 17:06:23 +08:00
Lykin 464a85867d fix: database switching confusion in different tabs 2023-12-04 17:06:23 +08:00
Lykin af6b4257f9 feat: add type display in key browser 2023-12-04 17:06:23 +08:00
Lykin b688ded610 fix: auto format detection may fail 2023-12-03 23:46:33 +08:00
Lykin a78c6cdb26 perf: add "show line numbers" preferences option 2023-12-03 23:46:33 +08:00
Lykin 8573d24a47 feat: add multiple check mode for keys
perf: add multiple keys deletion and progress indicator
2023-12-03 23:46:33 +08:00
Lykin 7c4f0197ba refactor: removed the usage of the NCode 2023-12-02 02:58:05 +08:00
Lykin 52490cb304 feat: add loaded progress for list/hash/set/zset/stream 2023-12-02 02:43:37 +08:00
Lykin 7934fc275a perf: save last selected database and restore for next connection 2023-12-02 02:03:29 +08:00
Lykin 2d3225dbcf perf: add copy value button for complex type #23 2023-12-02 01:10:37 +08:00
Lykin 988e8e3339 fix: the value is not updated after refreshing #90 2023-12-02 00:26:32 +08:00
Lykin ab8077999d fix: display incorrect value in zset content
fix: content pane cause error when value is null
2023-12-01 18:42:39 +08:00
Lykin eb8f68b628 perf: optimized the appearance 2023-12-01 18:03:19 +08:00
Lykin e2f33af1c7 feat: the browser pane is now set to display only a single database
feat: add search input in browser pane
2023-12-01 18:02:32 +08:00
Lykin 8201004478 feat: add filter input to browser pane 2023-11-29 16:16:13 +08:00
Lykin 379bb5e623 feat: add custom monaco editor theme 2023-11-29 01:12:23 +08:00
Lykin 6b1fcb3779 feat: embedded monaco editor for complex types 2023-11-29 00:24:42 +08:00
Lykin 8662027a68 fix: errors occur when attempting to execute delete or rename in content page #86 2023-11-29 00:23:48 +08:00
Lykin f0e54f280c fix: disable illegal characters for renaming connection group 2023-11-29 00:23:48 +08:00
Lykin 9671f89770 feat: embedded monaco editor for string 2023-11-29 00:23:48 +08:00
Lykin 2f2fa6bb02 perf: save window maximised state #84 2023-11-24 11:49:59 +08:00
Lykin d40839506c doc: add discord and twitter links 2023-11-24 10:57:43 +08:00
Lykin 71978c325b fix: pin button in editor can not show on Linux 2023-11-24 00:52:48 +08:00
Lykin 79c943f85d perf: use ZRangeWithScores to scan partial for ZSet 2023-11-24 00:22:40 +08:00
Lykin 66a057f504 chore: update dependencies 2023-11-23 11:25:53 +08:00
Lykin 28eb393a82 perf: enable default context menu #73 2023-11-23 11:13:21 +08:00
Lykin bfb5407030 fix: support output map[any]any type to console #81 2023-11-21 20:06:28 +08:00
Lykin 4ffbdbd39a perf: move cursor to last when switch to history by arrow up or down #77 2023-11-21 18:27:09 +08:00
Lykin 30d6da85a1 fix: can not open connection when "CLIENT" command killed #78 2023-11-21 18:13:26 +08:00
Lykin 9aaf32e2bf fix: test connection status may display incorrectly #79 2023-11-21 17:39:04 +08:00
Lykin 7b18ed0b26 fix: the console output content lacks line breaks effect #80 2023-11-21 17:23:31 +08:00
Lykin e7fd13d608 feat: add full search for stream 2023-11-21 17:06:26 +08:00
Lykin 6a048037b0 feat: add full search for set/zset 2023-11-21 16:44:33 +08:00
Lykin eca640fc87 feat: add full search for hash 2023-11-20 18:38:23 +08:00
Lykin db4e2385fc feat: add full search for list 2023-11-20 18:37:43 +08:00
Lykin aafa0c5432 perf: use relative position for entry editor to toggle fullscreen 2023-11-18 01:30:26 +08:00
Lykin 2a57248228 refactor: optimized refresh logic after update fields for stream type 2023-11-18 00:51:40 +08:00
Lykin 13f343977a chore: refine ui details 2023-11-18 00:14:38 +08:00
Lykin ddc3868f22 refactor: optimized refresh logic after update fields for set/zset type 2023-11-17 18:41:15 +08:00
Lykin 4db3909c6a refactor: optimized refresh logic after update fields for list type 2023-11-17 17:20:32 +08:00
Lykin 80e659e03a refactor: optimized refresh logic after update fields for hash type 2023-11-17 16:26:03 +08:00
Lykin d88fd35e9d refactor: refactoring the refresh logic for all complex types after adding new fields 2023-11-17 01:24:04 +08:00
Lykin 45e9c49f26 docs: update bug report template 2023-11-16 23:54:55 +08:00
Lykin 578a8413a1 perf: support format viewing for entry value #65
fix: table columns lose reactive
2023-11-16 01:07:40 +08:00
Lykin cc436ad86f perf: support json format viewing for stream values #65
fix: the 'reset' parameter in 'GetKeyDetail' isn't taking effect
2023-11-16 01:07:39 +08:00
Lykin fea87662de perf: sorted set value support format viewing #65 2023-11-15 23:41:53 +08:00
Lykin 2a3a15d64d style: change side navigation menu style
style: change some theme color
2023-11-15 12:05:14 +08:00
Lykin 3c1727db3e perf: add pin and fullscreen supported for entry editor 2023-11-14 22:58:44 +08:00
Lykin 5b84fa59f6 perf: set value support format viewing #65 2023-11-14 17:15:02 +08:00
Lykin e0a8599e95 perf: list value support format viewing #65 2023-11-14 14:49:16 +08:00
tiny-craft 200b12cd51 refactor: rename "viewas" to "format", rename "plain text" to "raw" 2023-11-13 22:41:33 +08:00
tiny-craft a2c2ceb483 perf: hash value support format viewing #65 2023-11-13 22:31:18 +08:00
tiny-craft a49d618288 feat: add decode and format selection for Hash edit
perf: move decode and format state management into the component internally

perf: add component FormatSelector and ContentEntryEditor

refactor: modified functions with an excessive number of parameters to accept an object as a parameter
2023-11-13 16:03:24 +08:00
tiny-craft a2ad85627e fix: edit state does not revert when switching keys
fix: some digits are incorrectly recognized as base64-encoded text
2023-11-10 23:51:39 +08:00
tiny-craft 21c63e2ac2 chore: update doc and dependencies 2023-11-10 16:34:40 +08:00
tiny-craft 65e077c0c0 fix: compatibility with older Redis versions without UNLINK command support #69
perf: add waiting indicator for deleting keys and flushing database
2023-11-10 16:26:12 +08:00
tiny-craft 5a58a57cd5 Merge remote-tracking branch 'origin/main' 2023-11-10 15:25:20 +08:00
Lykin 73c14204b7
Merge pull request #71 from leognutzmann/main
feat: Portuguese language support
2023-11-10 15:19:28 +08:00
tiny-craft 7b3c3df230 feat: add key layer reload/refresh back after database full loaded #72
perf: update database max keys each load next/all.
2023-11-10 13:01:18 +08:00
tiny-craft fa4929cd63 perf: add reset handle for all content components 2023-11-09 20:31:07 +08:00
tiny-craft 352e7b714d refactor: optimize the key renaming logic 2023-11-09 14:42:35 +08:00
tiny-craft 9618990de8 feat: add partial entries loading for complex type(list/hash/set/zset/stream) #70
refactor: split "key value loading" into "key summary loading" and "key detail loading"
2023-11-09 14:42:35 +08:00
tiny-craft f2ebd7f358 chore: update dependencies 2023-11-08 00:37:30 +08:00
Leonardo Gnutzmann 3d0310a9c1 feat: add portuguese 2023-11-06 16:23:11 -03:00
tiny-craft e6b28c9edc fix: incorrect max keys count after add or delete keys 2023-11-05 13:00:03 +08:00
tiny-craft e28d091500 refactor: split connection_service into connection_service and browser_service in go
refactor: split connectionStore into connectionStore and browserStore in js
2023-11-05 11:58:59 +08:00
tiny-craft 44df1d5800 doc: update screenshots 2023-11-03 17:55:41 +08:00
tiny-craft 75abd66c46 fix: try unescape client name in slow log
doc: update screenshots
2023-11-03 10:50:51 +08:00
tiny-craft dc39b5ea3e doc: update README and screenshots 2023-11-03 10:16:40 +08:00
tiny-craft b95f293185 feat: add slow log 2023-11-02 23:18:20 +08:00
tiny-craft 9b9d0e3c7c feat: add flush db operation 2023-11-02 18:17:54 +08:00
tiny-craft 67666f4edf fix: unable to decode json string with prefix or suffix contains space or line break 2023-11-02 16:27:55 +08:00
tiny-craft 37e31b1636 doc: update README 2023-11-02 15:52:43 +08:00
tiny-craft 4c016b06de doc: add contributing and issue template 2023-11-02 15:52:43 +08:00
tiny-craft 4e38978711 perf: standardized localized file names 2023-11-02 14:41:07 +08:00
tiny-craft 64895a98af fix: unable to save json string with prefix or suffix contains space or line break 2023-11-01 23:35:45 +08:00
tiny-craft 6996421cde fix: can not save scan size less than default value in preference
fix: incorrect delete pattern when flush database with any key selected
2023-10-31 22:33:58 +08:00
tiny-craft 5a29a34ea1 fix: add cli fit the window when resize 2023-10-31 17:57:54 +08:00
tiny-craft ca8403d784 perf: keep view format and decode method when reload the same key 2023-10-31 17:29:56 +08:00
tiny-craft 51ed14dcf8 style: add padding to message component
chore: update dependencies
2023-10-31 16:12:13 +08:00
tiny-craft c6f1daed44 fix: cursor offset incorrect when prompt prefix contains multibyte char in cli 2023-10-31 11:55:34 +08:00
tiny-craft 929c4d2794 fix: window may flash when startup 2023-10-31 11:45:55 +08:00
tiny-craft 694214bedf feat: add ZSTD decode/encode support #59 2023-10-31 11:43:46 +08:00
tiny-craft ff8da4ca60 fix: long string overflow in value page
fix: border radius show and hide logic incorrect on Windows
2023-10-30 21:54:20 +08:00
tiny-craft 0143e8f52a perf: cropped icon size 2023-10-30 11:52:46 +08:00
tiny-craft fd99b43798 feat: add display of content length and size info 2023-10-30 00:55:50 +08:00
tiny-craft 30866d33f0 feat: split "view as" to "view type" and "decode type" #60
feat: add footer to the value content page
2023-10-30 00:55:49 +08:00
tiny-craft d0af3deaeb fix: add windows outline border back on macOS 2023-10-30 00:18:34 +08:00
tiny-craft faad24d1d5 perf: force change icon button color when then state is loading 2023-10-28 22:42:15 +08:00
tiny-craft 6fb0eadfbd style: adjusted the light and dark themes
style: add window shadow on macOS #62
2023-10-28 22:31:02 +08:00
tiny-craft 124627d724 refactor: wrapping element with drag and resize behavior as a standalone component 2023-10-27 21:53:05 +08:00
tiny-craft b6eebda177 style: update style of browser tabs
style: update style of connection dialog
2023-10-27 21:53:05 +08:00
tiny-craft 33051f4e46 fix: last preferences did not clone correctly 2023-10-27 13:01:18 +08:00
tiny-craft 85ab6e4377 style: formatted code 2023-10-27 11:34:40 +08:00
tiny-craft 297286f150 perf: create "content-pane" component for each tab to maintain data and status easily 2023-10-27 11:34:40 +08:00
tiny-craft 54864fe7d5 chore: update dependencies 2023-10-26 19:29:26 +08:00
tiny-craft 1cf893149a feat: add command line mode 2023-10-26 19:29:26 +08:00
tiny-craft 5b4683a735 feat: adjusted the content pane to accommodate more information (add sub content tabs). 2023-10-23 19:45:04 +08:00
tiny-craft 6f5ea748f5 refactor: update icons' property 2023-10-23 18:22:43 +08:00
tiny-craft acd3fa9304 style: update the tab style 2023-10-23 01:01:29 +08:00
tiny-craft f9fe74a6b4 perf: move key view switch to connection preferences 2023-10-22 18:32:47 +08:00
tiny-craft 92ffd6c605 perf: optimized keyboard support for context menus 2023-10-22 18:04:17 +08:00
tiny-craft c4e4ed7e79 perf: change "load all keys" to "load all left keys" 2023-10-22 02:18:38 +08:00
tiny-craft 5bbd87cc31 perf: reset expand key record when reload or close database 2023-10-22 02:09:11 +08:00
tiny-craft a669f3dfcb feat: add "tree view" and "list view" switch for keys browser 2023-10-22 01:54:22 +08:00
tiny-craft 34a0be4d08 feat: add new component "switch button"
refactor: extract the file open input as a component
2023-10-21 00:12:22 +08:00
tiny-craft f8a7567166 refactor: reformat frontend files
chore: upgrade frontedn dependencies
2023-10-21 00:11:36 +08:00
tiny-craft f3cd292af5 feat: add partial keys loading. #2 2023-10-21 00:11:35 +08:00
tiny-craft 444f643d4a perf: add loading property to icon button 2023-10-20 18:24:26 +08:00
tiny-craft 25cdfa2685 fix: error causes when close database 2023-10-19 12:00:10 +08:00
tiny-craft 22d3954e6f chore: rename platform tag of macOS dmg from "amd64" to "intel" 2023-10-17 21:17:43 +08:00
tiny-craft 8868f37828 fix: "CLIENT SETNAME" causes error with invalid character #56 2023-10-17 17:26:10 +08:00
tiny-craft 7b3f526cd4 chore: update dependencies and redis type color 2023-10-17 17:24:23 +08:00
tiny-craft 94e352dd30 chore: add Google Analytics event for record app startup 2023-10-17 10:19:50 +08:00
tiny-craft 77541ed371 perf: change the function buttons for database nodes to be persistent 2023-10-16 18:36:42 +08:00
tiny-craft e2a371ed14 perf: use pipeline for batch delete keys 2023-10-16 11:22:18 +08:00
tiny-craft 72144bc996 perf: add minimum window size
perf: maximize the window automatically if screen size is lower than minimum window size #19
2023-10-16 10:26:39 +08:00
tiny-craft d61eb1323f feat: add sync/async delete key option to delete key dialog 2023-10-16 01:09:04 +08:00
tiny-craft 4a0807e463 chore: forbid rename key in cluster mode 2023-10-15 22:40:10 +08:00
tiny-craft 78bac6078a perf: display human-readable ttl #54 2023-10-15 21:50:41 +08:00
tiny-craft 7bcd78f321 Merge remote-tracking branch 'origin/main' 2023-10-15 19:55:04 +08:00
Lykin c0bf201697
Merge pull request #54 from ikaven1024/feat-ttl
perf: display human-readable ttl
2023-10-15 19:53:41 +08:00
tiny-craft ad9f13d557 perf: use goroutine to implement window resize events.
perf: optimized window display in fullscreen and maximized modes. #19
2023-10-15 19:49:33 +08:00
tiny-craft 2db858ba9e feat: support ssl connection 2023-10-15 19:49:33 +08:00
tiny-craft 444d0ea199 feat: support cluster mode 2023-10-15 19:49:33 +08:00
ikaven1024 95af057dd5 feat: display human-readable ttl 2023-10-15 19:10:08 +08:00
tiny-craft 44f8581a41 fix: incorrect keys count display of database after delete the only remaining key. #47 2023-10-11 16:25:35 +08:00
tiny-craft 76734989d5 fix: big number lose precision via view as JSON format #52 2023-10-11 15:48:29 +08:00
tiny-craft a4412d21d4 perf: support display binary key name which unreadable(convert to hex string) #49 2023-10-11 01:15:23 +08:00
tiny-craft a6645e3340 fix: Chinese characters are incorrectly recognized as binary. #50 2023-10-09 23:10:31 +08:00
tiny-craft ca663d8a55 perf: use "Keyspace" to determine database count if "CONFIG GET database" execute fail 2023-10-09 11:09:00 +08:00
tiny-craft 97f6ded7e0 fix: retrieved database count does not match 2023-10-09 00:53:08 +08:00
tiny-craft ab2056ba3e fix: preference saving might fail 2023-10-08 23:37:07 +08:00
tiny-craft b3c494f15e feat: added support for viewing in brotli decompression 2023-10-08 23:29:43 +08:00
tiny-craft 30e7016aa3 fix: database index confused after setting database filter 2023-10-08 18:25:52 +08:00
tiny-craft b5dfe377fa feat: add discovery master group name for sentinel mode config
perf: tidy connection profile file
2023-10-08 15:24:08 +08:00
tiny-craft ee68d699fa feat: support sentinel mode #16 #42 2023-10-08 01:33:03 +08:00
tiny-craft 477ed19d20 perf: update version compare logic 2023-10-07 23:37:12 +08:00
tiny-craft d2aa9317b9 chore: update dependencies to latest 2023-10-07 22:15:06 +08:00
tiny-craft 1a49db2450 feat: add cancel operation to the waiting spin for opening connections #9
chore: update common message config
2023-10-07 18:32:42 +08:00
tiny-craft efc09a8745 refactor: separate define for all opened server status info 2023-10-07 15:24:02 +08:00
tiny-craft bb1d9f316b feat: add filter database option #18
refactor: tidy struct of translation file
2023-10-07 10:49:29 +08:00
tiny-craft 7937b5b0f8 perf: optimized string viewing logic 2023-10-05 23:54:43 +08:00
tiny-craft ec7a7f18e9 fix: HSET command causes error when accepts multiple field and value #44 2023-10-05 20:40:32 +08:00
tiny-craft aad4bcf17b fix: incorrect status display in connection dialog. 2023-10-05 20:40:31 +08:00
249 changed files with 30479 additions and 8354 deletions

43
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,43 @@
## Tiny RDM Contribute Guide
### Multi-language Contributions
#### Adding New Language
1. New file: Add a new JSON file in the [frontend/src/langs](../frontend/src/langs/), with the file naming format is "
{language}-{region}.json", e.g. English is "en-us.json", simplified Chinese is "zh-cn.json". Highly recommended to duplicate the [en-us.json](../frontend/src/langs/en-us.json) file and rename it.
2. Fill content: Refer to [en-us.json](../frontend/src/langs/en-us.json), or duplicate the file and modify the language content.
3. Update codes: Edit[frontend/src/langs/index.js](.../frontend/src/langs/index.js), import the new language data inside.
```javascript
import en from './en-us'
// import your new localize file 'zh-cn' here
import zh from './zh-cn'
export const lang = {
en,
// export new language data 'zh' here
zh,
}
```
4. Submit review once there are no issues with the translation context in the application. (learn how to submit)
### Code Submission`(To be completed)`
#### Pull Request Title
The format of PR's title like "<type>: <description>"
- type: PR type
- description: PR description
PR type list below:
| type | description |
|----------|----------------------------------------------------|
| revert | Revert a commit |
| feat | New features |
| perf | Performance improvements |
| fix | Fix any bugs |
| style | Style updates |
| docs | Document updates |
| refactor | Code refactors |
| chore | Some chores |
| ci | Automation process configuration or script updates |

42
.github/CONTRIBUTING_zh.md vendored Normal file
View File

@ -0,0 +1,42 @@
## Tiny RDM 代码贡献指南
### 多国语言贡献
#### 增加新的语言
1. 创建文件:在[frontend/src/langs](../frontend/src/langs/)目录下新增语言配置JSON文件文件名格式为“{语言}-{地区}.json”如英文为“en-us.json”简体中文为“zh-cn.json”建议直接复制[en-us.json](../frontend/src/langs/en-us.json)文件进行改名。
2. 填充内容:参考[en-us.json](../frontend/src/langs/en-us.json),或者直接克隆一份文件,对语言部分内容进行修改。
3. 代码修改:在[frontend/src/langs/index.js](.../frontend/src/langs/index.js)文件内导入新增的语言数据
```javascript
import en from './en-us'
// import your new localize file 'zh-cn' here
import zh from './zh-cn'
export const lang = {
en,
// export new language data 'zh' here
zh,
}
```
4. 检查应用中对应翻译语境无问题后,可提交审核([查看如何提交](#pull_request)
### 代码提交`(待完善)`
#### PR提交规范
PR提交格式为“<type>: <description>
- type: 提交类型
- description: 提交内容描述
其中提交类型如下:
| 提交类型 | 类型描述 |
|----------|--------------|
| revert | 回退某个commit提交 |
| feat | 新功能/新特性 |
| perf | 功能、体验等方面的优化 |
| fix | 修复问题 |
| style | 样式相关修改 |
| docs | 文档更新 |
| refactor | 代码重构 |
| chore | 杂项修改 |
| ci | 自动化流程配置或脚本修改 |

24
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,24 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG]'
labels: ''
assignees: ''
---
**Tiny RDM Version**
What version of Tiny RDM are you using?
**OS Version**
Which OS and version you launch? (Mac/Windows/Linux)
**Redis Version**
Which version of Redis are you using?
**Describe the bug**
A clear and concise description of what the bug is.
Steps to Reproduce:
1.
2.

View File

@ -0,0 +1,5 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE]'
---

View File

@ -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
@ -63,7 +84,10 @@ jobs:
- name: Build wails app for Linux
shell: bash
run: CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} -ldflags "-X main.version=${{ github.event.release.tag_name }}" -o tiny-rdm
run: |
CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \
-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
shell: bash
@ -95,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 }}

View File

@ -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:
@ -22,15 +28,20 @@ jobs:
id: normalise_platform
shell: bash
run: |
tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g' -e 's/darwin/mac/g')
tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g' -e 's/darwin/mac/g' -e 's/amd64/intel/g')
echo "tag=$tag" >> "$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
@ -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
@ -69,7 +80,9 @@ jobs:
- name: Build wails app for macOS
shell: bash
run: CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} -ldflags "-X main.version=${{ github.event.release.tag_name }}"
run: |
CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \
-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
@ -78,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 }}

View File

@ -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
@ -62,26 +81,31 @@ jobs:
- name: Build Windows portable app
shell: bash
run: CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} -upx -webview2 embed -ldflags "-X main.version=${{ github.event.release.tag_name }}"
run: |
CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \
-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"
@ -89,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 }}

2
.gitignore vendored
View File

@ -2,6 +2,8 @@ build/bin
node_modules
frontend/dist
frontend/wailsjs
frontend/package.json.md5
design/
.vscode
.idea
test

View File

@ -1,32 +1,60 @@
<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">
[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)
[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)
![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)
[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)
[![Discord](https://img.shields.io/discord/1170373259133456434?label=Discord&color=5865F2)](https://discord.gg/VTFbBMGjWh)
[![X](https://img.shields.io/badge/Twitter-black?logo=x&logoColor=white)](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>
![](screenshots/light_en.png)
<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>
## Feature
* Built on Webview, no embedded browsers (Thanks to [Wails](https://github.com/wailsapp/wails)).
* More elegant UI and visualized layout (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 (Click here to contribute and support more languages).
* Convenient data viewing and editing.
* More features under continuous development...
* Multi-language support ([Need more languages ? Click here to contribute](.github/CONTRIBUTING.md)).
* 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.
* 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
We publish binaries for Mac, Windows, and Linux.
Available to download for free from [here](https://github.com/tiny-craft/tiny-rdm/releases).
> If you can't open it after installation on macOS, exec the following command then reopen:
@ -34,32 +62,54 @@ Available to download for free from [here](https://github.com/tiny-craft/tiny-rd
> sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app
> ```
## Build
## Build Guidelines
### Prerequisites
* Go >= 1.21
* 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" />

111
README_ja.md Normal file
View File

@ -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">
[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)
[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)
![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)
[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)
[![Discord](https://img.shields.io/discord/1170373259133456434?label=Discord&color=5865F2)](https://discord.gg/VTFbBMGjWh)
[![X](https://img.shields.io/badge/Twitter-black?logo=x&logoColor=white)](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" />

View File

@ -1,32 +1,56 @@
<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">
[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)
[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)
![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)
[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)
<strong>一个现代化轻量级的跨平台Redis桌面客户端支持Mac、Windows和Linux</strong>
</div>
![screenshot](screenshots/light_zh.png)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/dark_zh.png">
<source media="(prefers-color-scheme: light)" srcset="screenshots/light_zh.png">
<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>
## 功能特性
* 基于Webview无内嵌浏览器感谢[Wails](https://github.com/wailsapp/wails)
* 更精美的界面和直观的结构布局(感谢[Naive UI](https://github.com/tusen-ai/naive-ui)
* 极度轻量,基于Webview2,无内嵌浏览器(感谢[Wails](https://github.com/wailsapp/wails)
* 界面精美易用,提供浅色/深色主题(感谢[Naive UI](https://github.com/tusen-ai/naive-ui)
和 [IconPark](https://iconpark.oceanengine.com)
* 多国语言支持(点我贡献和完善多国语言支持)
* 便捷的数据查看和编辑修改
* 更多功能持续开发中…
* 多国语言支持:英文/中文([需要更多语言支持?点我贡献语言](.github/CONTRIBUTING_zh.md)
* 更好用的连接管理支持SSH隧道/SSL/哨兵模式/集群模式/HTTP代理/SOCKS5代理
* 可视化键值操作,增删查改一应俱全
* 支持多种数据查看格式以及转码/解压方式
* 采用SCAN分段加载可轻松处理数百万键列表
* 操作命令执行日志展示
* 提供命令行操作
* 提供慢日志展示
* List/Hash/Set/Sorted Set的分段加载和查询
* List/Hash/Set/Sorted Set值的转码显示
* 内置高级编辑器Monaco Editor
* 支持命令实时监控
* 支持导入/导出数据
* 支持发布订阅
* 支持导入/导出连接配置
* 自定义数据展示编码/解码([这是操作指引](https://redis.tinycraft.cc/zh/guide/custom-decoder/))
## 安装
提供Mac、Windows和Linux下载安装可[免费下载](https://github.com/tiny-craft/tiny-rdm/releases)。
提供Mac、Windows和Linux安装,可[免费下载](https://github.com/tiny-craft/tiny-rdm/releases)。
> 如果在macOS上安装后无法打开报错**不受信任**或者**移到垃圾箱**,执行下面命令后再启动即可:
> ``` shell
@ -34,36 +58,68 @@
> ```
## 构建项目
### 运行环境要求
* Go >= 1.21
* 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" />

27
app.go
View File

@ -1,27 +0,0 @@
package main
import (
"context"
"fmt"
)
// App struct
type App struct {
ctx context.Context
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}

View File

@ -4,3 +4,7 @@ const DEFAULT_FONT_SIZE = 14
const DEFAULT_ASIDE_WIDTH = 300
const DEFAULT_WINDOW_WIDTH = 1024
const DEFAULT_WINDOW_HEIGHT = 768
const MIN_WINDOW_WIDTH = 960
const MIN_WINDOW_HEIGHT = 640
const DEFAULT_LOAD_SIZE = 10000
const DEFAULT_SCAN_SIZE = 3000

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,161 @@
package services
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"github.com/wailsapp/wails/v2/pkg/runtime"
"strings"
"sync"
"tinyrdm/backend/types"
sliceutil "tinyrdm/backend/utils/slice"
strutil "tinyrdm/backend/utils/string"
)
type cliService struct {
ctx context.Context
ctxCancel context.CancelFunc
mutex sync.Mutex
clients map[string]redis.UniversalClient
selectedDB map[string]int
}
type cliOutput struct {
Content []string `json:"content"` // output content
Prompt string `json:"prompt,omitempty"` // new line prompt, empty if not ready to input
}
var cli *cliService
var onceCli sync.Once
func Cli() *cliService {
if cli == nil {
onceCli.Do(func() {
cli = &cliService{
clients: map[string]redis.UniversalClient{},
selectedDB: map[string]int{},
}
})
}
return cli
}
func (c *cliService) runCommand(server, data string) {
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 || errors.Is(err, redis.Nil) {
if strings.ToLower(cmds[0]) == "select" {
// switch database
if db, ok := strutil.AnyToInt(cmds[1]); ok {
c.selectedDB[server] = db
}
}
c.echo(server, strutil.AnyToString(result, "", 0), true)
} else {
c.echoError(server, err.Error())
}
return
}
}
c.echoReady(server)
}
func (c *cliService) echo(server, data string, newLineReady bool) {
output := cliOutput{
Content: strings.Split(data, "\n"),
}
if newLineReady {
output.Prompt = fmt.Sprintf("%s:db%d> ", server, c.selectedDB[server])
}
runtime.EventsEmit(c.ctx, "cmd:output:"+server, output)
}
func (c *cliService) echoReady(server string) {
c.echo(server, "", true)
}
func (c *cliService) echoError(server, data string) {
c.echo(server, "\x1b[31m"+data+"\x1b[0m", true)
}
func (c *cliService) getRedisClient(server string) (redis.UniversalClient, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
client, ok := c.clients[server]
if !ok {
var err error
conf := Connection().getConnection(server)
if conf == nil {
return nil, fmt.Errorf("no connection profile named: %s", server)
}
if client, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil {
return nil, err
}
c.clients[server] = client
}
return client, nil
}
func (c *cliService) Start(ctx context.Context) {
c.ctx, c.ctxCancel = context.WithCancel(ctx)
}
// StartCli start a cli session
func (c *cliService) StartCli(server string, db int) (resp types.JSResp) {
client, err := c.getRedisClient(server)
if err != nil {
resp.Msg = err.Error()
return
}
client.Do(c.ctx, "select", db)
c.selectedDB[server] = db
// monitor input
runtime.EventsOn(c.ctx, "cmd:input:"+server, func(data ...interface{}) {
if len(data) > 0 {
if str, ok := data[0].(string); ok {
c.runCommand(server, str)
return
}
}
c.echoReady(server)
})
// echo prefix
c.echoReady(server)
resp.Success = true
return
}
// CloseCli close cli session
func (c *cliService) CloseCli(server string) (resp types.JSResp) {
c.mutex.Lock()
defer c.mutex.Unlock()
if client, ok := c.clients[server]; ok {
client.Close()
delete(c.clients, server)
delete(c.selectedDB, server)
}
runtime.EventsOff(c.ctx, "cmd:input:"+server)
resp.Success = true
return
}
// CloseAll close all cli sessions
func (c *cliService) CloseAll() {
if c.ctxCancel != nil {
c.ctxCancel()
}
for server := range c.clients {
c.CloseCli(server)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,110 @@
package services
import (
"bytes"
"encoding/json"
"github.com/google/uuid"
"net/http"
"runtime"
"strings"
"sync"
"tinyrdm/backend/storage"
)
// google analytics service
type gaService struct {
measurementID string
secretKey string
clientID string
}
type GaDataItem struct {
ClientID string `json:"client_id"`
Events []GaEventItem `json:"events"`
}
type GaEventItem struct {
Name string `json:"name"`
Params map[string]any `json:"params"`
}
var ga *gaService
var onceGA sync.Once
func GA() *gaService {
if ga == nil {
onceGA.Do(func() {
// get or create an unique user id
st := storage.NewLocalStore("device.txt")
uidByte, err := st.Load()
if err != nil {
uidByte = []byte(strings.ReplaceAll(uuid.NewString(), "-", ""))
st.Store(uidByte)
}
ga = &gaService{
clientID: string(uidByte),
}
})
}
return ga
}
func (a *gaService) SetSecretKey(measurementID, secretKey string) {
a.measurementID = measurementID
a.secretKey = secretKey
}
func (a *gaService) isValid() bool {
return len(a.measurementID) > 0 && len(a.secretKey) > 0
}
func (a *gaService) sendEvent(events ...GaEventItem) error {
body, err := json.Marshal(GaDataItem{
ClientID: a.clientID,
Events: events,
})
if err != nil {
return err
}
//url := "https://www.google-analytics.com/debug/mp/collect"
url := "https://www.google-analytics.com/mp/collect"
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
q := req.URL.Query()
q.Add("measurement_id", a.measurementID)
q.Add("api_secret", a.secretKey)
req.URL.RawQuery = q.Encode()
response, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
//if dump, err := httputil.DumpResponse(response, true); err == nil {
// log.Println(string(dump))
//}
return nil
}
// Startup sends application startup event
func (a *gaService) Startup(version string) {
if !a.isValid() {
return
}
go a.sendEvent(GaEventItem{
Name: "startup",
Params: map[string]any{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"version": version,
},
})
}

View File

@ -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
}

View File

@ -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,16 +137,24 @@ func (p *preferencesService) GetAppVersion() (resp types.JSResp) {
return
}
func (p *preferencesService) SaveWindowSize(width, height int) {
p.UpdatePreferences(map[string]any{
"behavior.windowWidth": width,
"behavior.windowHeight": 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.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
}
@ -131,22 +164,89 @@ func (p *preferencesService) GetWindowSize() (width, height int) {
return
}
type latestRelease struct {
Name string `json:"name"`
TagName string `json:"tag_name"`
Url string `json:"url"`
HtmlUrl string `json:"html_url"`
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
if size <= 0 {
size = consts.DEFAULT_SCAN_SIZE
}
return size
}
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"
@ -156,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")
}
}

View File

@ -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)
}
}

View File

@ -1,18 +0,0 @@
package services
import "sync"
type storageService struct {
}
var storage *storageService
var onceStorage sync.Once
func Storage() *storageService {
if storage == nil {
onceStorage.Do(func() {
storage = &storageService{}
})
}
return storage
}

View File

@ -0,0 +1,166 @@
package services
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
runtime2 "runtime"
"sync"
"time"
"tinyrdm/backend/consts"
"tinyrdm/backend/types"
sliceutil "tinyrdm/backend/utils/slice"
)
type systemService struct {
ctx context.Context
appVersion string
}
var system *systemService
var onceSystem sync.Once
func System() *systemService {
if system == nil {
onceSystem.Do(func() {
system = &systemService{
appVersion: "0.0.0",
}
go system.loopWindowEvent()
})
}
return system
}
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 {
for _, sc := range screen {
if sc.IsCurrent {
if sc.Size.Width < consts.MIN_WINDOW_WIDTH || sc.Size.Height < consts.MIN_WINDOW_HEIGHT {
runtime.WindowMaximise(ctx)
break
}
}
}
}
}
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, 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 {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"path": filepath,
}
return
}
func (s *systemService) loopWindowEvent() {
var fullscreen, maximised, minimised, normal bool
var width, height int
var dirty bool
for {
time.Sleep(300 * time.Millisecond)
if s.ctx == nil {
continue
}
dirty = false
if f := runtime.WindowIsFullscreen(s.ctx); f != fullscreen {
// full-screen switched
fullscreen = f
dirty = true
}
if w, h := runtime.WindowGetSize(s.ctx); w != width || h != height {
// window size changed
width, height = w, h
dirty = true
}
if m := runtime.WindowIsMaximised(s.ctx); m != maximised {
maximised = m
dirty = true
}
if m := runtime.WindowIsMinimised(s.ctx); m != minimised {
minimised = m
dirty = true
}
if n := runtime.WindowIsNormal(s.ctx); n != normal {
normal = n
dirty = true
}
if dirty {
runtime.EventsEmit(s.ctx, "window_changed", map[string]any{
"fullscreen": fullscreen,
"width": width,
"height": height,
"maximised": maximised,
"minimised": minimised,
"normal": normal,
})
if !fullscreen && !minimised {
// save window size and position
Preferences().SaveWindowSize(width, height, maximised)
}
}
}
}

View File

@ -3,9 +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 {
@ -25,23 +26,31 @@ 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,
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",
},
}
}
func (c *ConnectionsStorage) getConnections() (ret types.Connections) {
b, err := c.storage.Load()
ret = c.defaultConnections()
if err != nil {
ret = c.defaultConnections()
return
}
@ -190,8 +199,7 @@ func (c *ConnectionsStorage) UpdateConnection(name string, param types.Connectio
updated = true
}
} else {
err := retrieve(conn.Connections, name, param)
if err != nil {
if err := retrieve(conn.Connections, name, param); err != nil {
return err
}
}
@ -248,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
@ -282,7 +290,7 @@ func (c *ConnectionsStorage) SaveSortedConnection(sortedConns types.Connections)
return c.saveConnections(conns)
}
// CreateGroup create new group
// CreateGroup create a new group
func (c *ConnectionsStorage) CreateGroup(name string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
@ -328,7 +336,7 @@ func (c *ConnectionsStorage) RenameGroup(name, newName string) error {
return c.saveConnections(conns)
}
// DeleteGroup remove special group, include all connections under it
// DeleteGroup remove specified group, include all connections under it
func (c *ConnectionsStorage) DeleteGroup(group string, includeConnection bool) error {
c.mutex.Lock()
defer c.mutex.Unlock()

View File

@ -7,6 +7,7 @@ import (
"reflect"
"strings"
"sync"
"tinyrdm/backend/consts"
"tinyrdm/backend/types"
)
@ -16,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,
}
}
@ -26,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
}
@ -45,6 +48,12 @@ func (p *PreferencesStorage) GetPreferences() (ret types.Preferences) {
defer p.mutex.Unlock()
ret = p.getPreferences()
if ret.General.ScanSize <= 0 {
ret.General.ScanSize = consts.DEFAULT_SCAN_SIZE
}
ret.Behavior.AsideWidth = max(ret.Behavior.AsideWidth, consts.DEFAULT_ASIDE_WIDTH)
ret.Behavior.WindowWidth = max(ret.Behavior.WindowWidth, consts.MIN_WINDOW_WIDTH)
ret.Behavior.WindowHeight = max(ret.Behavior.WindowHeight, consts.MIN_WINDOW_HEIGHT)
return
}

View File

@ -3,18 +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"`
MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"`
SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,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 {
@ -25,25 +38,51 @@ type Connection struct {
type Connections []Connection
type ConnectionGroup struct {
GroupName string `json:"groupName" yaml:"group_name"`
Connections []Connection `json:"connections" yaml:"connections"`
}
type ConnectionDB struct {
Name string `json:"name"`
Keys int `json:"keys"`
Alias string `json:"alias,omitempty"`
Index int `json:"index"`
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"`
AllowInsecure bool `json:"allowInsecure,omitempty" yaml:"allow_insecure,omitempty"`
SNI string `json:"sni,omitempty" yaml:"sni,omitempty"`
}
type ConnectionSSH struct {
Enable bool `json:"enable" yaml:"enable"`
Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"`
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
Port int `json:"port,omitempty" yaml:"port,omitempty"`
LoginType string `json:"loginType" yaml:"login_type"`
Username string `json:"username" yaml:"username"`
LoginType string `json:"loginType,omitempty" yaml:"login_type"`
Username string `json:"username,omitempty" yaml:"username,omitempty"`
Password string `json:"password,omitempty" yaml:"password,omitempty"`
PKFile string `json:"pkFile,omitempty" yaml:"pk_file,omitempty"`
Passphrase string `json:"passphrase,omitempty" yaml:"passphrase,omitempty"`
}
type ConnectionSentinel struct {
Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"`
Master string `json:"master,omitempty" yaml:"master,omitempty"`
Username string `json:"username,omitempty" yaml:"username,omitempty"`
Password string `json:"password,omitempty" yaml:"password,omitempty"`
}
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"`
}

View File

@ -5,3 +5,108 @@ type JSResp struct {
Msg string `json:"msg"`
Data any `json:"data,omitempty"`
}
type KeySummaryParam struct {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
}
type KeySummary struct {
Type string `json:"type"`
TTL int64 `json:"ttl,omitempty"`
Size int64 `json:"size,omitempty"`
Length int64 `json:"length,omitempty"`
}
type KeyDetailParam struct {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
Format string `json:"format,omitempty"`
Decode string `json:"decode,omitempty"`
MatchPattern string `json:"matchPattern,omitempty"`
Reset bool `json:"reset"`
Full bool `json:"full"`
}
type KeyDetail struct {
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 {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
KeyType string `json:"keyType"`
Value any `json:"value"`
TTL int64 `json:"ttl"`
Format string `json:"format,omitempty"`
Decode string `json:"decode,omitempty"`
}
type SetListParam struct {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
Index int `json:"index"`
Value any `json:"value"`
Format string `json:"format,omitempty"`
Decode string `json:"decode,omitempty"`
RetFormat string `json:"retFormat,omitempty"`
RetDecode string `json:"retDecode,omitempty"`
}
type SetHashParam struct {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
Field string `json:"field,omitempty"`
NewField string `json:"newField,omitempty"`
Value any `json:"value"`
Format string `json:"format,omitempty"`
Decode string `json:"decode,omitempty"`
RetFormat string `json:"retFormat,omitempty"`
RetDecode string `json:"retDecode,omitempty"`
}
type SetSetParam struct {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
Value any `json:"value"`
NewValue any `json:"newValue"`
Format string `json:"format,omitempty"`
Decode string `json:"decode,omitempty"`
RetFormat string `json:"retFormat,omitempty"`
RetDecode string `json:"retDecode,omitempty"`
}
type SetZSetParam struct {
Server string `json:"server"`
DB int `json:"db"`
Key any `json:"key"`
Value any `json:"value"`
NewValue any `json:"newValue"`
Score float64 `json:"score"`
Format string `json:"format,omitempty"`
Decode string `json:"decode,omitempty"`
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"`
}

View File

@ -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,35 +18,78 @@ func NewPreferences() Preferences {
WindowHeight: consts.DEFAULT_WINDOW_HEIGHT,
},
General: PreferencesGeneral{
Theme: "auto",
Language: "auto",
FontSize: consts.DEFAULT_FONT_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"`
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"`
}

View File

@ -1,11 +1,51 @@
package types
type ZSetItem struct {
Value string `json:"value"`
Score float64 `json:"score"`
type ListEntryItem struct {
Index int `json:"index"`
Value any `json:"v"`
DisplayValue string `json:"dv,omitempty"`
}
type StreamItem struct {
ID string `json:"id"`
Value map[string]any `json:"value"`
type ListReplaceItem struct {
Index int `json:"index"`
Value any `json:"v,omitempty"`
DisplayValue string `json:"dv,omitempty"`
}
type HashEntryItem struct {
Key string `json:"k"`
Value any `json:"v"`
DisplayValue string `json:"dv,omitempty"`
}
type HashReplaceItem struct {
Key any `json:"k"`
NewKey any `json:"nk"`
Value any `json:"v"`
DisplayValue string `json:"dv,omitempty"`
}
type SetEntryItem struct {
Value any `json:"v"`
DisplayValue string `json:"dv,omitempty"`
}
type ZSetEntryItem struct {
Score float64 `json:"s"`
ScoreStr string `json:"ss,omitempty"`
Value any `json:"v"`
DisplayValue string `json:"dv,omitempty"`
}
type ZSetReplaceItem struct {
Score float64 `json:"s"`
Value string `json:"v"`
NewValue string `json:"nv"`
DisplayValue string `json:"dv,omitempty"`
}
type StreamEntryItem struct {
ID string `json:"id"`
Value map[string]any `json:"v"`
DisplayValue string `json:"dv,omitempty"`
}

View File

@ -1,11 +1,20 @@
package types
const PLAIN_TEXT = "Plain Text"
const JSON = "JSON"
const BASE64_TEXT = "Base64 Text"
const BASE64_JSON = "Base64 JSON"
const HEX = "Hex"
const BINARY = "Binary"
const GZIP = "GZip"
const GZIP_JSON = "GZip JSON"
const DEFLATE = "Deflate"
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"
const DECODE_NONE = "None"
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"

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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{}

View File

@ -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)
}

View File

@ -76,17 +76,17 @@ func (l *LogHook) DialHook(next redis.DialHook) redis.DialHook {
func (l *LogHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
log.Println(cmd)
t := time.Now()
err := next(ctx, cmd)
if l.cmdExec != nil {
b := make([]byte, 0, 64)
for i, arg := range cmd.Args() {
if i > 0 {
b = append(b, ' ')
}
b = appendArg(b, arg)
b := make([]byte, 0, 64)
for i, arg := range cmd.Args() {
if i > 0 {
b = append(b, ' ')
}
b = appendArg(b, arg)
}
log.Println(string(b))
if l.cmdExec != nil {
l.cmdExec(string(b), time.Since(t).Milliseconds())
}
return err
@ -98,19 +98,24 @@ func (l *LogHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.Proc
t := time.Now()
err := next(ctx, cmds)
cost := time.Since(t).Milliseconds()
for _, cmd := range cmds {
b := make([]byte, 0, 64)
for i, cmd := range cmds {
log.Println("pipeline: ", cmd)
if l.cmdExec != nil {
b := make([]byte, 0, 64)
for i, arg := range cmd.Args() {
if i > 0 {
b = append(b, ' ')
}
b = appendArg(b, arg)
}
l.cmdExec(string(b), cost)
if i != len(cmds) {
b = append(b, '\n')
}
}
}
if l.cmdExec != nil {
l.cmdExec(string(b), cost)
}
return err
}
}

View File

@ -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
}

View File

@ -0,0 +1,172 @@
package strutil
import (
"encoding/json"
"strconv"
"strings"
sliceutil "tinyrdm/backend/utils/slice"
)
func AnyToString(value interface{}, prefix string, layer int) (s string) {
if value == nil {
return
}
switch value.(type) {
case float64:
ft := value.(float64)
s = strconv.FormatFloat(ft, 'f', -1, 64)
case float32:
ft := value.(float32)
s = strconv.FormatFloat(float64(ft), 'f', -1, 64)
case int:
it := value.(int)
s = strconv.Itoa(it)
case uint:
it := value.(uint)
s = strconv.Itoa(int(it))
case int8:
it := value.(int8)
s = strconv.Itoa(int(it))
case uint8:
it := value.(uint8)
s = strconv.Itoa(int(it))
case int16:
it := value.(int16)
s = strconv.Itoa(int(it))
case uint16:
it := value.(uint16)
s = strconv.Itoa(int(it))
case int32:
it := value.(int32)
s = strconv.Itoa(int(it))
case uint32:
it := value.(uint32)
s = strconv.Itoa(int(it))
case int64:
it := value.(int64)
s = strconv.FormatInt(it, 10)
case uint64:
it := value.(uint64)
s = strconv.FormatUint(it, 10)
case string:
if layer > 0 {
s = "\"" + value.(string) + "\""
} else {
s = value.(string)
}
case bool:
val, _ := value.(bool)
if val {
s = "True"
} else {
s = "False"
}
case []byte:
s = prefix + string(value.([]byte))
case []string:
ss := value.([]string)
anyStr := sliceutil.Map(ss, func(i int) string {
str := AnyToString(ss[i], prefix, layer+1)
return prefix + strconv.Itoa(i+1) + ") " + str
})
s = prefix + sliceutil.JoinString(anyStr, "\r\n")
case []any:
as := value.([]any)
anyItems := sliceutil.Map(as, func(i int) string {
str := AnyToString(as[i], prefix, layer+1)
return prefix + strconv.Itoa(i+1) + ") " + str
})
s = sliceutil.JoinString(anyItems, "\r\n")
case map[any]any:
am := value.(map[any]any)
var items []string
index := 0
for k, v := range am {
kk := prefix + strconv.Itoa(index+1) + ") " + AnyToString(k, prefix, layer+1)
vv := prefix + strconv.Itoa(index+2) + ") " + AnyToString(v, "\t", layer+1)
if layer > 0 {
indent := layer
if index == 0 {
indent -= 1
}
for i := 0; i < indent; i++ {
vv = " " + vv
}
}
index += 2
items = append(items, kk, vv)
}
s = sliceutil.JoinString(items, "\r\n")
default:
b, _ := json.Marshal(value)
s = prefix + string(b)
}
return
}
//func AnyToHex(val any) (string, bool) {
// var src string
// switch val.(type) {
// case string:
// src = val.(string)
// case []byte:
// src = string(val.([]byte))
// }
//
// if len(src) <= 0 {
// return "", false
// }
//
// var output strings.Builder
// for i := range src {
// if !utf8.ValidString(src[i : i+1]) {
// output.WriteString(fmt.Sprintf("\\x%02x", src[i:i+1]))
// } else {
// output.WriteString(src[i : i+1])
// }
// }
//
// 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
}

View File

@ -0,0 +1,42 @@
package strutil
import (
"unicode"
)
func ContainsBinary(str string) bool {
//buf := []byte(str)
//size := 0
//for start := 0; start < len(buf); start += size {
// var r rune
// if r, size = utf8.DecodeRune(buf[start:]); r == utf8.RuneError {
// return true
// }
//}
rs := []rune(str)
for _, r := range rs {
if r == unicode.ReplacementChar {
return true
}
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
return true
}
}
return false
}
func IsSameChar(str string) bool {
if len(str) <= 0 {
return false
}
rs := []rune(str)
first := rs[0]
for _, r := range rs {
if r != first {
return false
}
}
return true
}

View File

@ -1,275 +0,0 @@
package strutil
import (
"bytes"
"compress/flate"
"compress/gzip"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"strings"
"tinyrdm/backend/types"
)
// ConvertTo convert string to specified type
// @param targetType empty string indicates automatic detection of the string type
func ConvertTo(str, targetType string) (value, resultType string) {
if len(str) <= 0 {
// empty content
if len(targetType) <= 0 {
resultType = types.PLAIN_TEXT
} else {
resultType = targetType
}
return
}
switch targetType {
case types.PLAIN_TEXT:
value = str
resultType = targetType
return
case types.JSON:
value, _ = decodeJson(str)
resultType = targetType
return
case types.BASE64_TEXT, types.BASE64_JSON:
if base64Str, ok := decodeBase64(str); ok {
if targetType == types.BASE64_JSON {
value, _ = decodeJson(base64Str)
} else {
value = base64Str
}
} else {
value = str
}
resultType = targetType
return
case types.HEX:
if hexStr, ok := decodeHex(str); ok {
log.Print(hexStr)
value = hexStr
} else {
value = str
}
resultType = targetType
return
case types.BINARY:
var binary strings.Builder
for _, char := range str {
binary.WriteString(fmt.Sprintf("%08b", int(char)))
}
value = binary.String()
resultType = targetType
return
case types.GZIP, types.GZIP_JSON:
if gzipStr, ok := decodeGZip(str); ok {
if targetType == types.BASE64_JSON {
value, _ = decodeJson(gzipStr)
} else {
value = gzipStr
}
} else {
value = str
}
resultType = targetType
return
case types.DEFLATE:
value, _ = decodeDeflate(str)
resultType = targetType
return
}
// type isn't specified or unknown, try to automatically detect and return converted value
return autoToType(str)
}
// attempt automatic convert to possible types
// if no conversion is possible, it will return the origin string value and "plain text" type
func autoToType(str string) (value, resultType string) {
if len(str) > 0 {
var ok bool
if value, ok = decodeJson(str); ok {
resultType = types.JSON
return
}
if value, ok = decodeBase64(str); ok {
if value, ok = decodeJson(value); ok {
resultType = types.BASE64_JSON
return
}
resultType = types.BASE64_TEXT
return
}
if value, ok = decodeGZip(str); ok {
resultType = types.GZIP
return
}
if value, ok = decodeDeflate(str); ok {
resultType = types.DEFLATE
return
}
}
value = str
resultType = types.PLAIN_TEXT
return
}
func decodeJson(str string) (string, bool) {
var data any
if (strings.HasPrefix(str, "{") && strings.HasSuffix(str, "}")) ||
(strings.HasPrefix(str, "[") && strings.HasSuffix(str, "]")) {
if err := json.Unmarshal([]byte(str), &data); err == nil {
var jsonByte []byte
if jsonByte, err = json.MarshalIndent(data, "", " "); err == nil {
return string(jsonByte), true
}
}
}
return str, false
}
func decodeBase64(str string) (string, bool) {
if decodedStr, err := base64.StdEncoding.DecodeString(str); err == nil {
return string(decodedStr), true
}
return str, false
}
func decodeHex(str string) (string, bool) {
encodeStr := hex.EncodeToString([]byte(str))
var resultStr strings.Builder
for i := 0; i < len(encodeStr); i += 2 {
resultStr.WriteString("\\x")
resultStr.WriteString(encodeStr[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 SaveAs(str, targetType string) (value string, err error) {
switch targetType {
case types.PLAIN_TEXT:
return str, nil
case types.BASE64_TEXT:
base64Str, _ := encodeBase64(str)
return base64Str, nil
case types.JSON, types.BASE64_JSON, types.GZIP_JSON:
if jsonStr, ok := encodeJson(str); ok {
if targetType == types.BASE64_JSON {
base64Str, _ := encodeBase64(jsonStr)
return base64Str, nil
} else {
return jsonStr, nil
}
} else {
return str, errors.New("invalid json")
}
case types.GZIP:
if gzipStr, ok := encodeGZip(str); ok {
return gzipStr, nil
} else {
return str, errors.New("fail to build gzip data")
}
case types.DEFLATE:
if deflateStr, ok := encodeDeflate(str); ok {
return deflateStr, nil
} else {
return str, errors.New("fail to build deflate data")
}
}
return str, errors.New("fail to save with unknown error")
}
func encodeJson(str string) (string, bool) {
var data any
if (strings.HasPrefix(str, "{") && strings.HasSuffix(str, "}")) ||
(strings.HasPrefix(str, "[") && strings.HasSuffix(str, "]")) {
if err := json.Unmarshal([]byte(str), &data); err == nil {
var jsonByte []byte
if jsonByte, err = json.Marshal(data); err == nil {
return string(jsonByte), true
}
}
}
return str, false
}
func encodeBase64(str string) (string, bool) {
return base64.StdEncoding.EncodeToString([]byte(str)), true
}
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
}

View File

@ -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
}

View File

@ -0,0 +1,76 @@
package strutil
import (
"strconv"
sliceutil "tinyrdm/backend/utils/slice"
)
// 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) {
b := []byte(key)
arr := make([]int, len(b))
for i, bb := range b {
arr[i] = int(bb)
}
return arr
}
return key
}
// DecodeRedisKey decode redis key to readable string
func DecodeRedisKey(key any) string {
switch key.(type) {
case string:
return key.(string)
case []any:
arr := key.([]any)
bytes := sliceutil.Map(arr, func(i int) byte {
if c, ok := AnyToInt(arr[i]); ok {
return byte(c)
}
return '0'
})
return string(bytes)
case []int:
arr := key.([]int)
b := make([]byte, len(arr))
for i, bb := range arr {
b[i] = byte(bb)
}
return string(b)
}
return ""
}
// AnyToInt convert any value to int
func AnyToInt(val any) (int, bool) {
switch val.(type) {
case string:
num, err := strconv.Atoi(val.(string))
if err != nil {
return 0, false
}
return num, true
case float64:
return int(val.(float64)), true
case float32:
return int(val.(float32)), true
case int64:
return int(val.(int64)), true
case int32:
return int(val.(int32)), true
case int:
return val.(int), true
case bool:
if val.(bool) {
return 1, true
} else {
return 0, true
}
}
return 0, false
}

View File

@ -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>

View File

@ -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>

BIN
build/dmg/background.tiff Normal file

Binary file not shown.

40
build/dmg/fix-app Executable file
View File

@ -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"

42
build/dmg/fix-app_zh Executable file
View File

@ -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 "本窗口可以关闭啦!"

View File

@ -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

View File

@ -1,8 +1,3 @@
# Vue 3 + Vite
# Frontend of Tiny RDM
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs,
check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
Use Vue3 + Vite

View File

@ -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

View File

@ -9,21 +9,26 @@
"preview": "vite preview"
},
"dependencies": {
"dayjs": "^1.11.10",
"highlight.js": "^11.8.0",
"chart.js": "^4.4.8",
"copy-text-to-clipboard": "^3.2.0",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"pinia": "^2.1.6",
"sass": "^1.68.0",
"vue": "^3.3.4",
"vue-i18n": "^9.4.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.3.4",
"naive-ui": "^2.34.4",
"prettier": "^3.0.3",
"unplugin-auto-import": "^0.16.6",
"unplugin-icons": "^0.17.0",
"unplugin-vue-components": "^0.25.2",
"vite": "^4.4.9"
"@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"
}
}

View File

@ -1 +1 @@
a65375421b9b10cadef51ed8edc1c6f1
47ebcfd89e9e219e5b4ccf43ca2aa197

View File

@ -4,25 +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 { themeOverrides } from '@/utils/theme.js'
import { Environment, WindowSetDarkTheme, WindowSetLightTheme } from 'wailsjs/runtime/runtime.js'
import { darkThemeOverrides, themeOverrides } from '@/utils/theme.js'
import AboutDialog from '@/components/dialogs/AboutDialog.vue'
hljs.registerLanguage('json', json)
hljs.registerLanguage('plaintext', plaintext)
import FlushDbDialog from '@/components/dialogs/FlushDbDialog.vue'
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()
@ -32,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
}
@ -56,11 +112,10 @@ watch(
<template>
<n-config-provider
:hljs="hljs"
:inline-theme-disabled="true"
:locale="prefStore.themeLocale"
:theme="prefStore.isDark ? darkTheme : undefined"
:theme-overrides="themeOverrides"
:theme-overrides="prefStore.isDark ? darkThemeOverrides : themeOverrides"
class="fill-height">
<n-dialog-provider>
<app-content :loading="initializing" />
@ -73,8 +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>

View File

@ -1,21 +1,22 @@
<script setup>
import ContentPane from './components/content/ContentPane.vue'
import BrowserPane from './components/sidebar/BrowserPane.vue'
import { computed, 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 NavMenu from './components/sidebar/NavMenu.vue'
import Ribbon from './components/sidebar/Ribbon.vue'
import ConnectionPane from './components/sidebar/ConnectionPane.vue'
import ContentServerPane from './components/content/ContentServerPane.vue'
import useTabStore from './stores/tab.js'
import usePreferencesStore from './stores/preferences.js'
import useConnectionStore from './stores/connections.js'
import ContentLogPane from './components/content/ContentLogPane.vue'
import ContentValueTab from '@/components/content/ContentValueTab.vue'
import ToolbarControlWidget from '@/components/common/ToolbarControlWidget.vue'
import { WindowIsFullscreen, WindowToggleMaximise } from 'wailsjs/runtime/runtime.js'
import { isMacOS } from '@/utils/platform.js'
import { EventsOn, WindowIsFullscreen, WindowIsMaximised, WindowToggleMaximise } from 'wailsjs/runtime/runtime.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'
const themeVars = useThemeVars()
@ -24,175 +25,210 @@ const props = defineProps({
})
const data = reactive({
navMenuWidth: 60,
hoverResize: false,
resizing: false,
toolbarHeight: 45,
navMenuWidth: 50,
toolbarHeight: 38,
})
const tabStore = useTabStore()
const prefStore = usePreferencesStore()
const connectionStore = useConnectionStore()
const logPaneRef = ref(null)
const exThemeVars = computed(() => {
return extraTheme(prefStore.isDark)
})
// const preferences = ref({})
// provide('preferences', preferences)
const saveWidth = debounce(prefStore.savePreferences, 1000, { trailing: true })
const handleResize = (evt) => {
if (data.resizing) {
prefStore.setAsideWidth(Math.max(evt.clientX - data.navMenuWidth, 300))
saveWidth()
const saveSidebarWidth = debounce(prefStore.savePreferences, 1000, { trailing: true })
const handleResize = () => {
saveSidebarWidth()
}
watchEffect(() => {
if (tabStore.nav === 'log') {
logPaneRef.value?.refresh()
}
})
const logoWrapperWidth = computed(() => {
return `${data.navMenuWidth + prefStore.behavior.asideWidth - 4}px`
})
const logoPaddingLeft = ref(10)
const maximised = ref(false)
const hideRadius = ref(false)
const wrapperStyle = computed(() => {
if (isWindows()) {
return {}
}
return hideRadius.value
? {}
: {
border: `1px solid ${themeVars.value.borderColor}`,
borderRadius: '10px',
}
})
const spinStyle = computed(() => {
if (isWindows()) {
return {
backgroundColor: themeVars.value.bodyColor,
}
}
return hideRadius.value
? {
backgroundColor: themeVars.value.bodyColor,
}
: {
backgroundColor: themeVars.value.bodyColor,
borderRadius: '10px',
}
})
const onToggleFullscreen = (fullscreen) => {
hideRadius.value = fullscreen
if (fullscreen) {
logoPaddingLeft.value = 10
} else {
logoPaddingLeft.value = isMacOS() ? 70 : 10
}
}
const stopResize = () => {
data.resizing = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
// TODO: Save sidebar x-position
}
const startResize = () => {
data.resizing = true
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
}
const asideWidthVal = computed(() => {
return prefStore.behavior.asideWidth + 'px'
})
const dragging = computed(() => {
return data.hoverResize || data.resizing
})
watch(
() => tabStore.nav,
(nav) => {
if (nav === 'log') {
logPaneRef.value?.refresh()
const onToggleMaximize = (isMaximised) => {
if (isMaximised) {
maximised.value = true
if (!isMacOS()) {
hideRadius.value = true
}
},
)
} else {
maximised.value = false
if (!isMacOS()) {
hideRadius.value = false
}
}
}
const borderRadius = computed(() => {
// FIXME: cannot get full screen status sync?
// if (isMacOS()) {
// return WindowIsFullscreen().then((full) => {
// return full ? '0' : '10px'
// })
// }
return '10px'
EventsOn('window_changed', (info) => {
const { fullscreen, maximised } = info
onToggleFullscreen(fullscreen === true)
onToggleMaximize(maximised)
})
const border = computed(() => {
const color = isMacOS() && false ? '#0000' : themeVars.value.borderColor
return `1px solid ${color}`
onMounted(async () => {
const fullscreen = await WindowIsFullscreen()
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>
<!-- app content-->
<n-spin
:show="props.loading"
:theme-overrides="{ opacitySpinning: 0 }"
:style="{ backgroundColor: themeVars.bodyColor, borderRadius, border }">
<div
id="app-content-wrapper"
class="flex-box-v"
:style="{
backgroundColor: themeVars.bodyColor,
color: themeVars.textColorBase,
}">
<n-spin :show="props.loading" :style="spinStyle" :theme-overrides="{ opacitySpinning: 0 }">
<div id="app-content-wrapper" :style="wrapperStyle" class="flex-box-v">
<!-- title bar -->
<div
id="app-toolbar"
:style="{ height: data.toolbarHeight + 'px' }"
class="flex-box-h"
style="--wails-draggable: drag"
:style="{ height: data.toolbarHeight + 'px' }"
@dblclick="WindowToggleMaximise">
<!-- title -->
<div
id="app-toolbar-title"
:style="{
width: `${data.navMenuWidth + prefStore.behavior.asideWidth - 4}px`,
paddingLeft: isMacOS() ? '70px' : '10px',
width: logoWrapperWidth,
minWidth: logoWrapperWidth,
paddingLeft: `${logoPaddingLeft}px`,
}">
<n-space align="center" :wrap-item="false" :wrap="false" :size="3">
<n-avatar :src="iconUrl" color="#0000" :size="35" style="min-width: 35px" />
<div style="min-width: 68px; font-weight: 800">Tiny RDM</div>
<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; white-space: nowrap; font-weight: 800">Tiny RDM</div>
<transition name="fade">
<n-text v-if="tabStore.nav === 'browser'" strong class="ellipsis" style="font-size: 13px">
- {{ get(tabStore.currentTab, 'name') }}
<n-text v-if="tabStore.nav === 'browser'" class="ellipsis" strong style="font-size: 13px">
- {{ tabStore.currentTabName }}
</n-text>
</transition>
</n-space>
</div>
<div
:class="{
'resize-divider-hover': data.hoverResize,
'resize-divider-drag': data.resizing,
}"
class="resize-divider resize-divider-hide"
@mousedown="startResize"
@mouseout="data.hoverResize = false"
@mouseover="data.hoverResize = true" />
<!-- browser tabs -->
<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()" :size="data.toolbarHeight" style="align-self: flex-start" />
<toolbar-control-widget
v-if="!isMacOS()"
:maximised="maximised"
:size="data.toolbarHeight"
style="align-self: flex-start" />
</div>
<!-- content -->
<div
id="app-content"
:style="prefStore.generalFont"
style="--wails-draggable: none"
class="flex-box-h flex-item-expand">
<nav-menu v-model:value="tabStore.nav" :width="data.navMenuWidth" />
class="flex-box-h flex-item-expand"
style="--wails-draggable: none">
<ribbon v-model:value="tabStore.nav" :width="data.navMenuWidth" />
<!-- browser page -->
<div v-show="tabStore.nav === 'browser'" :class="{ dragging }" class="flex-box-h flex-item-expand">
<div id="app-side" :style="{ width: asideWidthVal }" class="flex-box-h flex-item">
<div v-show="tabStore.nav === 'browser'" class="content-area flex-box-h flex-item-expand">
<resizeable-wrapper
v-model:size="prefStore.behavior.asideWidth"
:min-size="300"
:offset="data.navMenuWidth"
class="flex-item"
@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"
class="flex-item-expand" />
<div
:class="{
'resize-divider-hover': data.hoverResize,
'resize-divider-drag': data.resizing,
}"
class="resize-divider"
@mousedown="startResize"
@mouseout="data.hoverResize = false"
@mouseover="data.hoverResize = true" />
</div>
<content-pane class="flex-item-expand" />
:db="t.db"
:server="t.name"
class="app-side flex-item-expand" />
</resizeable-wrapper>
<content-pane
v-for="t in tabStore.tabs"
v-show="tabStore.currentTabName === t.name"
:key="t.name"
:server="t.name"
class="flex-item-expand" />
</div>
<!-- server list page -->
<div v-show="tabStore.nav === 'server'" :class="{ dragging }" class="flex-box-h flex-item-expand">
<div id="app-side" :style="{ width: asideWidthVal }" class="flex-box-h flex-item">
<connection-pane class="flex-item-expand" />
<div
:class="{
'resize-divider-hover': data.hoverResize,
'resize-divider-drag': data.resizing,
}"
class="resize-divider"
@mousedown="startResize"
@mouseout="data.hoverResize = false"
@mouseover="data.hoverResize = true" />
</div>
<div v-show="tabStore.nav === 'server'" class="content-area flex-box-h flex-item-expand">
<resizeable-wrapper
v-model:size="prefStore.behavior.asideWidth"
:min-size="300"
:offset="data.navMenuWidth"
class="flex-item"
@update:size="handleResize">
<connection-pane class="app-side flex-item-expand" />
</resizeable-wrapper>
<content-server-pane class="flex-item-expand" />
</div>
<!-- log page -->
<div v-show="tabStore.nav === 'log'" class="flex-box-h flex-item-expand">
<div v-show="tabStore.nav === 'log'" class="content-area flex-box-h flex-item-expand">
<content-log-pane ref="logPaneRef" class="flex-item-expand" />
</div>
</div>
@ -202,15 +238,16 @@ const border = computed(() => {
<style lang="scss" scoped>
#app-content-wrapper {
width: calc(100vw - 2px);
height: calc(100vh - 2px);
width: 100vw;
height: 100vh;
overflow: hidden;
box-sizing: border-box;
border-radius: 10px;
background-color: v-bind('themeVars.bodyColor');
color: v-bind('themeVars.textColorBase');
#app-toolbar {
background-color: v-bind('themeVars.tabColor');
border-bottom: 1px solid v-bind('themeVars.borderColor');
background-color: v-bind('exThemeVars.titleColor');
border-bottom: 1px solid v-bind('exThemeVars.splitColor');
&-title {
padding-left: 10px;
@ -225,43 +262,25 @@ const border = computed(() => {
align-self: flex-end;
margin-bottom: -1px;
margin-left: 3px;
overflow: auto;
}
#app-content {
height: calc(100% - 60px);
.content-area {
overflow: hidden;
}
}
#app-side {
.app-side {
//overflow: hidden;
height: 100%;
background-color: v-bind('themeVars.tabColor');
background-color: v-bind('exThemeVars.sidebarColor');
border-right: 1px solid v-bind('exThemeVars.splitColor');
}
}
.resize-divider {
width: 3px;
border-right: 1px solid v-bind('themeVars.borderColor');
}
.resize-divider-hide {
background-color: #0000;
border-right-color: #0000;
}
.resize-divider-hover {
background-color: v-bind('themeVars.borderColor');
border-right-color: v-bind('themeVars.borderColor');
}
.resize-divider-drag {
background-color: v-bind('themeVars.primaryColor');
border-right-color: v-bind('themeVars.primaryColor');
}
.dragging {
cursor: col-resize !important;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -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>

View File

@ -0,0 +1,148 @@
<script setup>
import { computed, h, ref } from 'vue'
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: {
type: String,
value: '',
},
options: {
type: Array,
value: () => [],
},
menuOption: {
type: Array,
value: () => [],
},
tooltip: {
type: String,
},
icon: [String, Object],
default: String,
disabled: Boolean,
})
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 dropdownOption = computed(() => {
const options = [
{
key: 'header',
type: 'render',
render: renderHeader,
},
{
key: 'header-divider',
type: 'divider',
},
]
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) => {
if (some(props.menuOption, { key })) {
emit('menu', key)
} else {
emit('update:value', key)
}
}
const buttonText = computed(() => {
return props.value || get(dropdownOption.value, [1, 'label'], props.default)
})
const showDropdown = ref(false)
const onDropdownShow = (show) => {
showDropdown.value = show === true
}
</script>
<template>
<n-dropdown
:disabled="props.disabled"
:options="dropdownOption"
:render-label="({ label }) => render.renderLabel(label, { class: 'type-selector-item' })"
:show-arrow="true"
:value="props.value"
trigger="click"
@select="onDropdownSelect"
@update:show="onDropdownShow">
<n-tooltip :disabled="showDropdown" :show-arrow="false">
{{ props.tooltip }}
<template #trigger>
<n-button :disabled="disabled" :focusable="false" quaternary>
<template #icon>
<n-icon>
<component :is="icon" />
</n-icon>
</template>
{{ buttonText }}
</n-button>
</template>
</n-tooltip>
</n-dropdown>
</template>
<style lang="scss">
.type-selector-header {
height: 30px;
line-height: 30px;
font-size: 15px;
font-weight: bold;
text-align: center;
padding: 0 10px;
}
.type-selector-item {
min-width: 100px;
text-align: center;
}
</style>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,47 @@
<script setup>
import { SelectFile } from 'wailsjs/go/services/systemService.js'
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('', isEmpty(props.ext) ? null : [props.ext])
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"
: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>
<style lang="scss" scoped></style>

View File

@ -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>

View File

@ -1,12 +1,15 @@
<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: {
type: [Number, String],
@ -14,36 +17,81 @@ const props = defineProps({
},
color: {
type: String,
default: 'currentColor',
default: '',
},
strokeWidth: {
type: [Number, String],
default: 3,
},
loading: Boolean,
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">
<n-tooltip v-if="hasTooltip" :delay="tooltipDelay" :keep-alive-on-hover="false" :show-arrow="false">
<template #trigger>
<n-button :disabled="disabled" :text="!border" :focusable="false" @click.prevent="emit('click')">
<n-icon :color="props.color" :size="props.size">
<component :is="props.icon" :stroke-width="props.strokeWidth" />
</n-icon>
<n-button
:class="props.buttonClass"
:color="props.color"
:disabled="props.disabled"
:focusable="false"
:loading="loading"
: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>
<n-icon :color="props.color || 'currentColor'" :size="props.size">
<component :is="props.icon" :stroke-width="props.strokeWidth" />
</n-icon>
</slot>
</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 :disabled="disabled" :text="!border" :focusable="false" @click.prevent="emit('click')">
<n-icon :color="props.color" :size="props.size">
<component :is="props.icon" :stroke-width="props.strokeWidth" />
</n-icon>
<n-button
v-else
:class="props.buttonClass"
:color="props.color"
:disabled="props.disabled"
:focusable="false"
:loading="loading"
: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>
<n-icon :color="props.color || 'currentColor'" :size="props.size">
<component :is="props.icon" :stroke-width="props.strokeWidth" />
</n-icon>
</slot>
</template>
</n-button>
</template>

View File

@ -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>

View File

@ -1,46 +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',
},
bordered: Boolean,
defaultLabel: String,
binaryKey: 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>

View File

@ -0,0 +1,123 @@
<script setup>
import { useThemeVars } from 'naive-ui'
import { ref } from 'vue'
/**
* Resizeable component wrapper
*/
const themeVars = useThemeVars()
const props = defineProps({
size: {
type: Number,
default: 100,
},
minSize: {
type: Number,
default: 300,
},
maxSize: {
type: Number,
default: 0,
},
offset: {
type: Number,
default: 0,
},
disabled: {
type: Boolean,
default: false,
},
borderWidth: {
type: Number,
default: 4,
},
})
const emit = defineEmits(['update:size'])
const resizing = ref(false)
const hover = ref(false)
const handleResize = (evt) => {
if (resizing.value) {
let size = evt.clientX - props.offset
if (size < props.minSize) {
size = props.minSize
}
if (props.maxSize > 0 && size > props.maxSize) {
size = props.maxSize
}
emit('update:size', size)
}
}
const stopResize = () => {
resizing.value = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
}
const startResize = () => {
if (props.disabled) {
return
}
resizing.value = true
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
}
const handleMouseOver = () => {
if (props.disabled) {
return
}
hover.value = true
}
</script>
<template>
<div :style="{ width: props.size + 'px' }" class="resize-wrapper flex-box-h">
<slot></slot>
<div
:class="{
'resize-divider-hover': hover,
'resize-divider-drag': resizing,
dragging: hover || resizing,
}"
:style="{ width: props.borderWidth + 'px', right: Math.floor(-props.borderWidth / 2) + 'px' }"
class="resize-divider"
@mousedown="startResize"
@mouseout="hover = false"
@mouseover="handleMouseOver" />
</div>
</template>
<style lang="scss" scoped>
.resize-wrapper {
position: relative;
.resize-divider {
position: absolute;
top: 0;
bottom: 0;
transition: background-color 0.3s ease-in;
z-index: 1;
}
.resize-divider-hide {
background-color: #0000;
}
.resize-divider-hover {
background-color: v-bind('themeVars.borderColor');
}
.resize-divider-drag {
background-color: v-bind('themeVars.primaryColor');
}
.dragging {
cursor: col-resize !important;
}
}
</style>

View File

@ -0,0 +1,70 @@
<script setup>
import { NIcon } from 'naive-ui'
const props = defineProps({
value: {
type: Number,
default: 0,
},
size: {
type: String,
default: 'small',
},
icons: Array,
tTooltips: Array,
tTooltipPlacement: {
type: String,
default: 'bottom',
},
iconSize: {
type: [Number, String],
default: 20,
},
color: {
type: String,
default: 'currentColor',
},
strokeWidth: {
type: [Number, String],
default: 3,
},
unselectStrokeWidth: {
type: [Number, String],
default: 3,
},
})
const emit = defineEmits(['update:value'])
const handleSwitch = (idx) => {
if (idx !== props.value) {
emit('update:value', idx)
}
}
</script>
<template>
<n-button-group>
<n-tooltip
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)">
<template #icon>
<n-icon :size="props.iconSize">
<component
:is="icon"
:stroke-width="i !== props.value ? props.unselectStrokeWidth : props.strokeWidth" />
</n-icon>
</template>
</n-button>
</template>
{{ props.tTooltips ? $t(props.tTooltips[i]) : '' }}
</n-tooltip>
</n-button-group>
</template>
<style lang="scss" scoped></style>

View File

@ -4,7 +4,8 @@ import WindowMax from '@/components/icons/WindowMax.vue'
import WindowClose from '@/components/icons/WindowClose.vue'
import { computed } from 'vue'
import { useThemeVars } from 'naive-ui'
import { Quit, WindowIsMaximised, WindowMinimise, WindowToggleMaximise } from 'wailsjs/runtime/runtime.js'
import { Quit, WindowMinimise, WindowToggleMaximise } from 'wailsjs/runtime/runtime.js'
import WindowRestore from '@/components/icons/WindowRestore.vue'
const themeVars = useThemeVars()
const props = defineProps({
@ -12,6 +13,9 @@ const props = defineProps({
type: Number,
default: 35,
},
maximised: {
type: Boolean,
},
})
const buttonSize = computed(() => {
@ -32,8 +36,8 @@ const handleClose = () => {
</script>
<template>
<n-space :wrap-item="false" align="center" justify="center" :size="0">
<n-tooltip :show-arrow="false">
<n-space :size="0" :wrap-item="false" align="center" justify="center">
<n-tooltip :delay="1000" :show-arrow="false">
{{ $t('menu.minimise') }}
<template #trigger>
<div class="btn-wrapper" @click="handleMinimise">
@ -41,15 +45,23 @@ const handleClose = () => {
</div>
</template>
</n-tooltip>
<n-tooltip :show-arrow="false">
{{ WindowIsMaximised() ? $t('menu.restore') : $t('menu.maximise') }}
<n-tooltip v-if="maximised" :delay="1000" :show-arrow="false">
{{ $t('menu.restore') }}
<template #trigger>
<div class="btn-wrapper" @click="handleMaximise">
<window-restore />
</div>
</template>
</n-tooltip>
<n-tooltip v-else :delay="1000" :show-arrow="false">
{{ $t('menu.maximise') }}
<template #trigger>
<div class="btn-wrapper" @click="handleMaximise">
<window-max />
</div>
</template>
</n-tooltip>
<n-tooltip :show-arrow="false">
<n-tooltip :delay="1000" :show-arrow="false">
{{ $t('menu.close') }}
<template #trigger>
<div class="btn-wrapper" @click="handleClose">
@ -60,7 +72,7 @@ const handleClose = () => {
</n-space>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
.btn-wrapper {
width: v-bind('buttonSize');
height: v-bind('buttonSize');

View File

@ -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>

View File

@ -1,14 +1,17 @@
<script setup>
import { computed, nextTick, reactive, ref } from 'vue'
import { computed, h, nextTick, reactive, ref } from 'vue'
import IconButton from '@/components/common/IconButton.vue'
import Refresh from '@/components/icons/Refresh.vue'
import useConnectionStore from 'stores/connections.js'
import { map, uniqBy } from 'lodash'
import { map, size, split, uniqBy } from 'lodash'
import { useI18n } from 'vue-i18n'
import Delete from '@/components/icons/Delete.vue'
import dayjs from 'dayjs'
import { useThemeVars } from 'naive-ui'
import useBrowserStore from 'stores/browser.js'
const connectionStore = useConnectionStore()
const themeVars = useThemeVars()
const browserStore = useBrowserStore()
const i18n = useI18n()
const data = reactive({
loading: false,
@ -23,7 +26,7 @@ const filterServerOption = computed(() => {
value: server,
}))
options.splice(0, 0, {
label: i18n.t('common.all'),
label: 'common.all',
value: '',
})
return options
@ -31,54 +34,116 @@ const filterServerOption = computed(() => {
const tableRef = ref(null)
const loadHistory = () => {
data.loading = true
connectionStore
.getCmdHistory()
.then((list) => {
data.history = list || []
})
.finally(() => {
data.loading = false
tableRef.value?.scrollTo({ top: 999999 })
})
const columns = computed(() => [
{
title: () => i18n.t('log.exec_time'),
key: 'timestamp',
defaultSortOrder: 'ascend',
sorter: 'default',
width: 180,
align: 'center',
titleAlign: 'center',
render: ({ timestamp }, index) => {
return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
},
},
{
title: () => i18n.t('log.server'),
key: 'server',
filterOptionValue: data.server,
filter: (value, row) => {
return value === '' || row.server === value.toString()
},
width: 150,
align: 'center',
titleAlign: 'center',
ellipsis: {
tooltip: true,
},
},
{
title: () => i18n.t('log.cmd'),
key: 'cmd',
titleAlign: 'center',
filterOptionValue: data.keyword,
resizable: true,
filter: (value, row) => {
return value === '' || !!~row.cmd.indexOf(value.toString())
},
render: ({ cmd }, index) => {
const cmdList = split(cmd, '\n')
if (size(cmdList) > 1) {
return h(
'div',
null,
map(cmdList, (c) => h('div', { class: 'cmd-line' }, c)),
)
}
return h('div', { class: 'cmd-line' }, cmd)
},
},
{
title: () => i18n.t('log.cost_time'),
key: 'cost',
width: 100,
align: 'center',
titleAlign: 'center',
render: ({ cost }, index) => {
const ms = dayjs.duration(cost).asMilliseconds()
if (ms < 1000) {
return `${ms} ms`
} else {
return `${Math.floor(ms / 1000)} s`
}
},
},
])
const loadHistory = async () => {
try {
await nextTick()
data.loading = true
const list = await browserStore.getCmdHistory()
data.history = list || []
} finally {
data.loading = false
await nextTick()
tableRef.value?.scrollTo({ position: 'bottom' })
}
}
const cleanHistory = async () => {
$dialog.warning(i18n.t('log.confirm_clean_log'), () => {
data.loading = true
connectionStore
.cleanCmdHistory()
.then((success) => {
if (success) {
data.history = []
tableRef.value?.scrollTo({ top: 0 })
$message.success(i18n.t('common.success'))
}
})
.finally(() => {
data.loading = false
})
$dialog.warning(i18n.t('log.confirm_clean_log'), async () => {
try {
data.loading = true
const success = await browserStore.cleanCmdHistory()
if (success) {
data.history = []
await nextTick()
tableRef.value?.scrollTo({ position: 'top' })
$message.success(i18n.t('dialogue.handle_succ'))
}
} finally {
data.loading = false
}
})
}
defineExpose({
refresh: () => nextTick().then(loadHistory),
refresh: loadHistory,
})
</script>
<template>
<n-card
:title="$t('log.launch_log')"
:bordered="false"
class="content-container flex-box-v"
content-style="display: flex;flex-direction: column; overflow: hidden;">
<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')">
@ -91,72 +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="[
{
title: $t('log.exec_time'),
key: 'timestamp',
defaultSortOrder: 'ascend',
sorter: 'default',
width: 180,
align: 'center',
titleAlign: 'center',
render({ timestamp }, index) {
return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
},
},
{
title: $t('log.server'),
key: 'server',
filterOptionValue: data.server,
filter(value, row) {
return value === '' || row.server === value.toString()
},
width: 150,
align: 'center',
titleAlign: 'center',
ellipsis: true,
},
{
title: $t('log.cmd'),
key: 'cmd',
titleAlign: 'center',
filterOptionValue: data.keyword,
resizable: true,
filter(value, row) {
return value === '' || !!~row.cmd.indexOf(value.toString())
},
},
{
title: $t('log.cost_time'),
key: 'cost',
width: 100,
align: 'center',
titleAlign: 'center',
render({ cost }, index) {
const ms = dayjs.duration(cost).asMilliseconds()
if (ms < 1000) {
return `${ms} ms`
} else {
return `${Math.floor(ms / 1000)} s`
}
},
},
]"
:data="data.history"
class="flex-item-expand"
flex-height />
</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>

View File

@ -1,73 +1,38 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { types } from '@/consts/support_redis_type.js'
import ContentValueHash from '@/components/content_value/ContentValueHash.vue'
import ContentValueList from '@/components/content_value/ContentValueList.vue'
import ContentValueString from '@/components/content_value/ContentValueString.vue'
import ContentValueSet from '@/components/content_value/ContentValueSet.vue'
import ContentValueZset from '@/components/content_value/ContentValueZSet.vue'
import { isEmpty, map, toUpper } from 'lodash'
import { computed, nextTick, ref, watch } from 'vue'
import { find, map, toUpper } from 'lodash'
import useTabStore from 'stores/tab.js'
import useConnectionStore from 'stores/connections.js'
import ContentServerStatus from '@/components/content_value/ContentServerStatus.vue'
import ContentValueStream from '@/components/content_value/ContentValueStream.vue'
import Status from '@/components/icons/Status.vue'
import { useThemeVars } from 'naive-ui'
import { BrowserTabType } from '@/consts/browser_tab_type.js'
import Terminal from '@/components/icons/Terminal.vue'
import Log from '@/components/icons/Log.vue'
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 ContentSlog from '@/components/content_value/ContentSlog.vue'
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 serverInfo = ref({})
const autoRefresh = ref(false)
const serverName = computed(() => {
if (tabContent.value != null) {
return tabContent.value.name
}
return ''
})
const loadingServerInfo = ref(false)
const autoLoadingServerInfo = ref(false)
const themeVars = useThemeVars()
/**
* refresh server status info
* @param {boolean} [force] force refresh will show loading indicator
* @returns {Promise<void>}
* @typedef {Object} ServerStatusItem
* @property {string} name
* @property {Object} info
* @property {boolean} autoRefresh
* @property {boolean} loading loading status for refresh
* @property {boolean} autoLoading loading status for auto refresh
*/
const refreshInfo = async (force) => {
if (force) {
loadingServerInfo.value = true
} else {
autoLoadingServerInfo.value = true
}
if (!isEmpty(serverName.value) && connectionStore.isConnected(serverName.value)) {
try {
serverInfo.value = await connectionStore.getServerInfo(serverName.value)
} finally {
loadingServerInfo.value = false
autoLoadingServerInfo.value = false
}
}
}
let intervalId
onMounted(() => {
refreshInfo(true)
intervalId = setInterval(() => {
if (autoRefresh.value) {
refreshInfo()
}
}, 5000)
const props = defineProps({
server: String,
})
onUnmounted(() => {
clearInterval(intervalId)
})
const valueComponents = {
[types.STRING]: ContentValueString,
[types.HASH]: ContentValueHash,
[types.LIST]: ContentValueList,
[types.SET]: ContentValueSet,
[types.ZSET]: ContentValueZset,
[types.STREAM]: ContentValueStream,
}
const connectionStore = useConnectionStore()
const tabStore = useTabStore()
const tab = computed(() =>
map(tabStore.tabs, (item) => ({
@ -76,114 +41,201 @@ const tab = computed(() =>
})),
)
watch(
() => tabStore.nav,
(nav) => {
if (nav === 'browser') {
refreshInfo()
}
},
)
const tabContent = computed(() => {
const tab = tabStore.currentTab
const tab = find(tabStore.tabs, { name: props.server })
if (tab == null) {
return null
return {}
}
return {
name: tab.name,
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,
viewAs: tab.viewAs,
length: tab.length || 0,
decode: tab.decode,
format: tab.format,
matchPattern: tab.matchPattern || '',
end: tab.end === true,
loading: tab.loading === true,
}
})
const showServerStatus = computed(() => {
return tabContent.value == null || isEmpty(tabContent.value.keyPath)
const isBlankValue = computed(() => {
return tabContent.value?.keyPath == null
})
const showNonexists = computed(() => {
return tabContent.value.value == null
const selectedSubTab = computed(() => {
const { subTab = BrowserTabType.Status } = tabStore.currentTab || {}
return subTab
})
const onUpdateValue = (tabIndex) => {
tabStore.switchTab(tabIndex)
}
/**
* reload current selection key
* @returns {Promise<null>}
*/
const onReloadKey = async () => {
const tab = tabStore.currentTab
if (tab == null || isEmpty(tab.key)) {
return null
}
await connectionStore.loadKeyValue(tab.name, tab.db, tab.key, tab.viewAs)
}
// BUG: naive-ui tabs will set the bottom line to '0px' after switch to another page and back again
// watch parent tabs' changing and call 'syncBarPosition' manually
const tabsRef = ref(null)
const cliRef = ref(null)
watch(
() => tabContent.value?.name,
(name) => {
if (name === props.server) {
nextTick().then(() => {
tabsRef.value?.syncBarPosition()
cliRef.value?.resizeTerm()
})
}
},
)
</script>
<template>
<div class="content-container flex-box-v">
<div v-if="showServerStatus" class="content-container flex-item-expand flex-box-v">
<!-- select nothing or select server node, display server status -->
<content-server-status
v-model:auto-refresh="autoRefresh"
:info="serverInfo"
:loading="loadingServerInfo"
:auto-loading="autoLoadingServerInfo"
:server="serverName"
@refresh="refreshInfo(true)" />
</div>
<div v-else-if="showNonexists" class="content-container flex-item-expand flex-box-v">
<n-empty :description="$t('interface.nonexist_tab_content')" class="empty-content">
<template #extra>
<n-button :focusable="false" @click="onReloadKey">{{ $t('interface.reload') }}</n-button>
<n-tabs
ref="tabsRef"
:tabs-padding="5"
:theme-overrides="{
tabFontWeightActive: 'normal',
tabGapSmallLine: '10px',
tabGapMediumLine: '10px',
tabGapLargeLine: '10px',
}"
:value="selectedSubTab"
class="content-sub-tab"
:default-value="BrowserTabType.Status.toString()"
pane-class="content-sub-tab-pane"
placement="top"
tab-style="padding-left: 10px; padding-right: 10px;"
type="line"
@update:value="tabStore.switchSubTab">
<!-- server status pane -->
<n-tab-pane :name="BrowserTabType.Status.toString()" display-directive="show:lazy">
<template #tab>
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
<n-icon size="16">
<status
:inverse="selectedSubTab === BrowserTabType.Status.toString()"
:stroke-color="themeVars.tabColor"
stroke-width="4" />
</n-icon>
<span>{{ $t('interface.sub_tab.status') }}</span>
</n-space>
</template>
</n-empty>
</div>
<component
:is="valueComponents[tabContent.type]"
v-else
:db="tabContent.db"
:key-path="tabContent.keyPath"
:name="tabContent.name"
:ttl="tabContent.ttl"
:value="tabContent.value"
:size="tabContent.size"
:view-as="tabContent.viewAs" />
<content-server-status
:pause="selectedSubTab !== BrowserTabType.Status.toString()"
:server="props.server" />
</n-tab-pane>
<!-- key detail pane -->
<n-tab-pane :name="BrowserTabType.KeyDetail.toString()" display-directive="show:lazy">
<template #tab>
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
<n-icon size="16">
<detail
:inverse="selectedSubTab === BrowserTabType.KeyDetail.toString()"
:stroke-color="themeVars.tabColor"
stroke-width="4" />
</n-icon>
<span>{{ $t('interface.sub_tab.key_detail') }}</span>
</n-space>
</template>
<content-value-wrapper :blank="isBlankValue" :content="tabContent" />
</n-tab-pane>
<!-- cli pane -->
<n-tab-pane :name="BrowserTabType.Cli.toString()" display-directive="show:lazy">
<template #tab>
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
<n-icon size="16">
<terminal
:inverse="selectedSubTab === BrowserTabType.Cli.toString()"
:stroke-color="themeVars.tabColor"
stroke-width="4" />
</n-icon>
<span>{{ $t('interface.sub_tab.cli') }}</span>
</n-space>
</template>
<content-cli ref="cliRef" :name="props.server" />
</n-tab-pane>
<!-- slow log pane -->
<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">
<log
:inverse="selectedSubTab === BrowserTabType.SlowLog.toString()"
:stroke-color="themeVars.tabColor"
stroke-width="4" />
</n-icon>
<span>{{ $t('interface.sub_tab.slow_log') }}</span>
</n-space>
</template>
<content-slog :server="props.server" />
</n-tab-pane>
<!-- command monitor pane -->
<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">
<monitor
:inverse="selectedSubTab === BrowserTabType.CmdMonitor.toString()"
:stroke-color="themeVars.tabColor"
stroke-width="4" />
</n-icon>
<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 :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">
<subscribe
:inverse="selectedSubTab === BrowserTabType.PubMessage.toString()"
:stroke-color="themeVars.tabColor"
stroke-width="4" />
</n-icon>
<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;
//padding: 5px 5px 0;
//padding-top: 0;
box-sizing: border-box;
background-color: v-bind('themeVars.tabColor');
}
</style>
<style lang="scss">
.content-sub-tab {
background-color: v-bind('themeVars.tabColor');
height: 100%;
}
//.tab-item {
// gap: 5px;
// padding: 0 5px 0 10px;
// align-items: center;
// max-width: 150px;
//
// transition: all var(--transition-duration-fast) var(--transition-function-ease-in-out-bezier);
//
// &-label {
// font-size: 15px;
// text-align: center;
// }
//
// &-close {
// &:hover {
// background-color: rgb(176, 177, 182, 0.4);
// }
// }
//}
.content-sub-tab-pane {
padding: 0 !important;
height: 100%;
background-color: v-bind('themeVars.bodyColor');
overflow: hidden;
}
.n-tabs .n-tabs-bar {
transition: none !important;
}
</style>

View File

@ -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 {

View File

@ -1,44 +1,42 @@
<script setup>
import ToggleServer from '@/components/icons/ToggleServer.vue'
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'
/**
* Value content tab on head
*/
const themeVars = useThemeVars()
const i18n = useI18n()
const tabStore = useTabStore()
const connectionStore = useConnectionStore()
const prefStore = usePreferencesStore()
const onCloseTab = (tabIndex) => {
$dialog.warning(i18n.t('dialogue.close_confirm'), () => {
const tab = get(tabStore.tabs, tabIndex)
if (tab != null) {
connectionStore.closeConnection(tab.name)
}
})
const tab = get(tabStore.tabs, tabIndex)
tabStore.closeTab(tab.name)
}
const activeTabStyle = computed(() => {
const { name } = tabStore.currentTab
const tabMarkColor = computed(() => {
const { name } = tabStore?.currentTab || {}
const { markColor = '' } = connectionStore.serverProfile[name] || {}
return {
backgroundColor: themeVars.value.bodyColor,
borderTopWidth: markColor ? '3px' : '1px',
borderTopColor: markColor || themeVars.value.borderColor,
borderBottomColor: themeVars.value.bodyColor,
borderTopLeftRadius: themeVars.value.borderRadius,
borderTopRightRadius: themeVars.value.borderRadius,
}
return markColor
})
const inactiveTabStyle = computed(() => ({
borderWidth: '0 0 1px',
// borderBottomColor: themeVars.value.borderColor,
borderTopLeftRadius: themeVars.value.borderRadius,
borderTopRightRadius: themeVars.value.borderRadius,
}))
const tabClass = (idx) => {
if (tabStore.activatedIndex === idx) {
return ['value-tab', 'value-tab-active', tabMarkColor.value ? 'value-tab-active_mark' : '']
} else if (tabStore.activatedIndex - 1 === idx) {
return ['value-tab', 'value-tab-inactive']
} else {
return ['value-tab', 'value-tab-inactive', 'value-tab-inactive2']
}
}
const tab = computed(() =>
map(tabStore.tabs, (item) => ({
@ -46,47 +44,78 @@ const tab = computed(() =>
label: item.title,
})),
)
const exThemeVars = computed(() => {
return extraTheme(prefStore.isDark)
})
</script>
<template>
<n-tabs
v-model:value="tabStore.activatedIndex"
:closable="true"
:tab-style="{
borderStyle: 'solid',
borderWidth: '1px',
borderLeftColor: themeVars.borderColor,
borderRightColor: themeVars.borderColor,
}"
size="small"
type="card"
@close="onCloseTab"
@update:value="(tabIndex) => tabStore.switchTab(tabIndex)"
:tabs-padding="3"
:theme-overrides="{
tabFontWeightActive: 800,
tabBorderRadius: 0,
tabGapSmallCard: 0,
tabGapMediumCard: 0,
tabGapLargeCard: 0,
tabColor: '#0000',
// tabBorderColor: themeVars.borderColor,
tabBorderColor: '#0000',
tabTextColorCard: themeVars.closeIconColor,
}">
<n-tab
v-for="(t, i) in tab"
:key="i"
:name="i"
:closable="tabStore.activatedIndex === i"
:style="tabStore.activatedIndex === i ? activeTabStyle : inactiveTabStyle"
style="--wails-draggable: none"
@dblclick.stop="() => {}">
<n-space align="center" justify="center" :wrap-item="false" :size="5" inline>
<n-icon :component="ToggleServer" size="18" />
}"
size="small"
type="card"
@close="onCloseTab"
@update:value="(tabIndex) => tabStore.switchTab(tabIndex)">
<n-tab v-for="(t, i) in tab" :key="i" :class="tabClass(i)" :closable="true" :name="i" @dblclick.stop="() => {}">
<n-space :size="5" :wrap-item="false" align="center" inline justify="center">
<n-icon size="18">
<server stroke-width="4" />
</n-icon>
<n-ellipsis style="max-width: 150px">{{ t.label }}</n-ellipsis>
</n-space>
</n-tab>
</n-tabs>
</template>
<style scoped lang="scss"></style>
<style lang="scss">
.value-tab {
--wails-draggable: none;
position: relative;
border: 1px solid v-bind('exThemeVars.splitColor') !important;
}
.value-tab-active {
background-color: v-bind('themeVars.tabColor') !important;
border-bottom-color: v-bind('themeVars.tabColor') !important;
&_mark {
border-top: 3px solid v-bind('tabMarkColor') !important;
}
}
.value-tab-inactive {
border-color: #0000 !important;
&:hover {
background-color: v-bind('exThemeVars.splitColor') !important;
}
}
.value-tab-inactive2 {
&:after {
content: '';
position: absolute;
top: 25%;
height: 50%;
width: 1px;
background-color: v-bind('themeVars.borderColor');
right: -2px;
}
&:hover::after {
background-color: #0000;
}
}
</style>

View File

@ -0,0 +1,589 @@
<script setup>
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { computed, defineExpose, onMounted, onUnmounted, ref, watch } from 'vue'
import 'xterm/css/xterm.css'
import { EventsEmit, EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'
import { get, isEmpty, set } from 'lodash'
import { CloseCli, StartCli } from 'wailsjs/go/services/cliService.js'
import usePreferencesStore from 'stores/preferences.js'
import { i18nGlobal } from '@/utils/i18n.js'
const props = defineProps({
name: String,
activated: Boolean,
})
const prefStore = usePreferencesStore()
const termRef = ref(null)
/**
*
* @type {xterm.Terminal|null}
*/
let termInst = null
/**
*
* @type {xterm-addon-fit.FitAddon|null}
*/
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,
fontFamily,
fontSize,
cursorStyle: prefStore.cli.cursorStyle || 'block',
cursorBlink: true,
disableStdin: false,
screenReaderMode: true,
// LogLevel: 'debug',
theme: {
// foreground: '#ECECEC',
background: '#000000',
// cursor: 'help',
// lineHeight: 20,
},
})
const fitAddon = new FitAddon()
term.open(termRef.value)
term.loadAddon(fitAddon)
term.onData(onTermData)
term.attachCustomKeyEventHandler(onTermKey)
return { term, fitAddon }
}
onMounted(() => {
const { term, fitAddon } = newTerm()
termInst = term
fitAddonInst = fitAddon
window.addEventListener('resize', resizeTerm)
term.writeln('\r\n' + i18nGlobal.t('interface.cli_welcome'))
// term.write('\x1b[4h') // insert mode
CloseCli(props.name)
StartCli(props.name, 0)
EventsOn(`cmd:output:${props.name}`, receiveTermOutput)
fitAddon.fit()
term.focus()
})
onUnmounted(() => {
window.removeEventListener('resize', resizeTerm)
EventsOff(`cmd:output:${props.name}`)
termInst.dispose()
termInst = null
console.warn('destroy term')
})
const resizeTerm = () => {
if (fitAddonInst != null) {
fitAddonInst.fit()
}
}
defineExpose({
resizeTerm,
})
watch(
() => 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()
},
)
const prefixContent = computed(() => {
return '\x1b[33m' + promptPrefix.value + '\x1b[0m'
})
const prefixLen = computed(() => {
let len = 0
for (let i = 0; i < promptPrefix.value.length; i++) {
const char = promptPrefix.value.charCodeAt(i)
if (char >= 0x0000 && char <= 0x00ff) {
// single byte ASCII char
len += 1
} else {
// multibyte Unicode char
len += 2
}
}
return len
})
let promptPrefix = ref('')
let inputCursor = 0
const inputHistory = []
let historyIndex = 0
let waitForOutput = false
const onTermData = (data) => {
if (termInst == null) {
return
}
if (data) {
const cc = data.charCodeAt(0)
switch (cc) {
case 127: // backspace
deleteInput(true)
return
case 13: // enter
// try to process local command first
switch (getCurrentInput()) {
case 'clear':
case 'clr':
termInst.clear()
replaceTermInput()
newInputLine()
return
default: // send command to server
flushTermInput()
return
}
case 27:
switch (data.substring(1)) {
case '[A': // arrow up
changeHistory(true)
return
case '[B': // arrow down
changeHistory(false)
return
case '[C': // arrow right ->
moveInputCursor(1)
return
case '[D': // arrow left <-
moveInputCursor(-1)
return
case '[3~': // del
deleteInput(false)
return
}
case 9: // tab
return
}
}
updateInput(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
*/
const moveInputCursor = (step) => {
if (termInst == null) {
return
}
let updateCursor = false
if (step > 0) {
// move right
const currentLine = getCurrentInput()
if (inputCursor + step <= currentLine.length) {
inputCursor += step
updateCursor = true
}
} else if (step < 0) {
// move left
if (inputCursor + step >= 0) {
inputCursor += step
updateCursor = true
}
}
if (updateCursor) {
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
*/
const updateInput = (data) => {
if (data == null || data.length <= 0) {
return
}
// replace &nbsp;(Non-Breaking Space) with normal blank space
data = data.replace(/\u00A0/g, ' ')
if (termInst == null) {
return
}
let currentLine = getCurrentInput()
if (inputCursor < currentLine.length) {
// insert
currentLine = currentLine.substring(0, inputCursor) + data + currentLine.substring(inputCursor)
replaceTermInput(currentLine)
moveInputCursor(data.length)
} else {
// append
currentLine += data
termInst.write(data)
inputCursor += data.length
}
updateCurrentInput(currentLine)
}
/**
*
* @param {boolean} back backspace or not
*/
const deleteInput = (back = false) => {
if (termInst == null) {
return
}
let currentLine = getCurrentInput()
if (inputCursor < currentLine.length) {
// delete middle part
if (back) {
currentLine = currentLine.substring(0, inputCursor - 1) + currentLine.substring(inputCursor)
inputCursor -= 1
} else {
currentLine = currentLine.substring(0, inputCursor) + currentLine.substring(inputCursor + 1)
}
} else {
if (back) {
// delete last one
currentLine = currentLine.slice(0, -1)
inputCursor -= 1
}
}
replaceTermInput(currentLine)
updateCurrentInput(currentLine)
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 = () => {
return get(inputHistory, historyIndex, '')
}
const updateCurrentInput = (input) => {
set(inputHistory, historyIndex, input || '')
}
const newInputLine = () => {
if (historyIndex >= 0 && historyIndex < inputHistory.length - 1) {
// edit prev history, move to last
const pop = inputHistory.splice(historyIndex, 1)
inputHistory[inputHistory.length - 1] = pop[0]
}
if (get(inputHistory, inputHistory.length - 1, '')) {
historyIndex = inputHistory.length
updateCurrentInput('')
}
}
/**
* get prev or next history record
* @param prev
* @return {*|null}
*/
const changeHistory = (prev) => {
let currentLine = null
if (prev) {
if (historyIndex > 0) {
historyIndex -= 1
currentLine = inputHistory[historyIndex]
}
} else {
if (historyIndex < inputHistory.length - 1) {
historyIndex += 1
currentLine = inputHistory[historyIndex]
}
}
if (currentLine != null) {
if (termInst == null) {
return
}
replaceTermInput(currentLine)
moveInputCursorToEnd()
}
return null
}
/**
* flush terminal input and send current prompt to server
* @param {boolean} flushCmd
*/
const flushTermInput = (flushCmd = false) => {
const currentLine = getCurrentInput()
EventsEmit(`cmd:input:${props.name}`, currentLine)
inputCursor = 0
// historyIndex = inputHistory.length
waitForOutput = true
}
/**
* clear current input line and replace with new content
* @param {string|null} [content]
*/
const replaceTermInput = (content = '') => {
if (termInst == null) {
return
}
// erase current line and write new content
termInst.write('\r\x1B[K' + prefixContent.value + (content || ''))
}
/**
* process receive output content
* @param {{content: string[], prompt: string}} data
*/
const receiveTermOutput = (data) => {
if (termInst == null) {
return
}
const { content = [], prompt } = data || {}
if (!isEmpty(content)) {
for (const line of content) {
termInst.write('\r\n' + line)
}
}
if (!isEmpty(prompt)) {
promptPrefix.value = prompt
termInst.write('\r\n' + prefixContent.value)
waitForOutput = false
inputCursor = 0
newInputLine()
}
}
</script>
<template>
<div ref="termRef" class="xterm" />
</template>
<style lang="scss" scoped>
.xterm {
width: 100%;
min-height: 100%;
overflow: hidden;
background-color: #000000;
}
</style>
<style lang="scss">
.xterm-screen {
padding: 0 5px !important;
}
.xterm-viewport::-webkit-scrollbar {
background-color: #000000;
width: 5px;
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: #000000;
}
.xterm-decoration-overview-ruler {
right: 1px;
pointer-events: none;
}
</style>

View File

@ -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>

View File

@ -0,0 +1,292 @@
<script setup>
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'
import { decodeRedisKey } from '@/utils/key_convert.js'
import useBrowserStore from 'stores/browser.js'
import FormatSelector from '@/components/content_value/FormatSelector.vue'
import IconButton from '@/components/common/IconButton.vue'
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, Array],
},
fieldLabel: {
type: String,
},
valueLabel: {
type: String,
},
decode: {
type: String,
},
format: {
type: String,
},
fieldReadonly: {
type: Boolean,
},
fullscreen: {
type: Boolean,
},
})
const themeVars = useThemeVars()
const browserStore = useBrowserStore()
const emit = defineEmits([
'update:field',
'update:value',
'update:decode',
'update:format',
'update:fullscreen',
'save',
'close',
])
watchEffect(
() => {
if (props.show && !isEmpty(props.keyPath)) {
onFormatChanged(props.decode, props.format)
} else {
viewAs.value = ''
}
},
{
flush: 'post',
},
)
const loading = ref(false)
const isPin = ref(false)
const viewAs = reactive({
field: '',
value: '',
format: formatTypes.RAW,
decode: decodeTypes.NONE,
})
const displayValue = computed(() => {
if (loading.value) {
return ''
}
if (viewAs.value == null) {
return decodeRedisKey(props.value)
}
return viewAs.value
})
const editingContent = ref('')
const enableSave = computed(() => {
return toString(props.field) !== viewAs.field || editingContent.value !== viewAs.value
})
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|null} decode
* @param {formatTypes|null} format
* @return {Promise<void>}
*/
const onFormatChanged = async (decode = null, format = null) => {
try {
loading.value = true
const {
value,
decode: retDecode,
format: retFormat,
} = await browserStore.convertValue({
value: props.value,
decode,
format,
})
viewAs.field = props.field + ''
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 onInput = (content) => {
editingContent.value = content
}
const onToggleFullscreen = () => {
emit('update:fullscreen', !!!props.fullscreen)
}
const onClose = () => {
isPin.value = false
emit('close')
}
const onSave = () => {
emit('save', viewAs.field, editingContent.value, viewAs.decode, viewAs.format)
if (!isPin.value) {
nextTick().then(onClose)
}
}
</script>
<template>
<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>
<n-input
v-model:value="viewAs.field"
:placeholder="props.field + ''"
:readonly="props.fieldReadonly"
class="editor-content-item-input"
type="text" />
</div>
<!-- value -->
<div class="editor-content-item flex-box-v flex-item-expand">
<div class="editor-content-item-label">{{ props.valueLabel }}</div>
<content-editor
:border="true"
:content="displayValue"
:key-path="viewAs.field"
:language="viewLanguage"
class="flex-item-expand"
@input="onInput"
@reset="onInput"
@save="onSave" />
<format-selector
:decode="viewAs.decode"
:format="viewAs.format"
style="margin-top: 5px"
@format-changed="(d, f) => onFormatChanged(d, f)" />
</div>
</div>
<template #header-extra>
<n-space :size="5">
<icon-button
: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-class="['pinable-btn', 'unpin-btn']"
:icon="props.fullscreen ? OffScreen : FullScreen"
:size="18"
stroke-width="5"
t-tooltip="interface.fullscreen"
@click="onToggleFullscreen" />
<icon-button
:button-class="['pinable-btn', 'unpin-btn']"
:icon="WindowClose"
:size="18"
stroke-width="5"
t-tooltip="menu.close"
@click="onClose" />
</n-space>
</template>
<template #action>
<n-space :wrap="false" :wrap-item="false" justify="end">
<n-button :disabled="!enableSave" :secondary="enableSave" type="primary" @click="onSave">
<template #icon>
<n-icon :component="Save" />
</template>
{{ $t('common.update') }}
</n-button>
</n-space>
</template>
</n-card>
</div>
</template>
<style lang="scss" scoped>
.entry-editor {
padding-left: 2px;
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 100;
.editor-content {
&-item {
&:not(:last-child) {
margin-bottom: 16px;
}
&-label {
height: 18px;
color: v-bind('themeVars.textColor3');
font-size: 13px;
padding: 5px 0;
}
&-input {
}
}
}
}
: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;
//}
</style>

View File

@ -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>&gt;</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>

View File

@ -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>

View File

@ -0,0 +1,196 @@
<script setup>
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 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, exact: boolean}>}
*/
const inputData = reactive({
match: '',
filter: '',
exact: false,
})
const hasMatch = computed(() => {
return !isEmpty(trim(inputData.match))
})
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, inputData.exact)
}
}
const onForceFullSearch = () => {
inputData.filter = trim(inputData.filter)
emit('matchChanged', inputData.match, inputData.filter, inputData.exact)
}
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, inputData.exact)
} else {
emit('filterChanged', inputData.filter, inputData.exact)
}
}
defineExpose({
reset: onClearFilter,
})
</script>
<template>
<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"
@keyup.enter="onFullSearch">
<template #prefix>
<slot name="prefix" />
<n-tooltip v-if="hasMatch" placement="bottom">
<template #trigger>
<n-tag closable size="small" @close="onClearMatch" @dblclick="onUpdateMatch">
{{ inputData.match }}
</n-tag>
</template>
{{
$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>
<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>
: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>

View File

@ -1,65 +1,292 @@
<script setup>
import { get, isEmpty, map, mapValues, pickBy, split, sum, toArray, toNumber } from 'lodash'
import { computed, 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,
info: Object,
autoRefresh: false,
loading: false,
autoLoading: false,
pause: Boolean,
})
const emit = defineEmits(['update:autoRefresh', 'refresh'])
const browserStore = useBrowserStore()
const prefStore = usePreferencesStore()
const connectionStore = useConnectionStore()
const i18n = useI18n()
const themeVars = useThemeVars()
const serverInfo = ref({})
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
* @param {boolean} [force] force refresh will show loading indicator
* @returns {Promise<void>}
*/
const refreshInfo = async (force) => {
if (force) {
pageState.loading = true
} else {
pageState.autoLoading = true
}
if (!isEmpty(props.server) && browserStore.isConnected(props.server)) {
try {
const info = await browserStore.getServerInfo(props.server, true)
if (!isEmpty(info)) {
serverInfo.value = info
_updateChart(info)
}
} finally {
pageState.loading = false
pageState.autoLoading = false
}
}
}
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))
}
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(() => {
stopAutoRefresh()
})
const scrollRef = ref(null)
const redisVersion = computed(() => {
return get(props.info, 'Server.redis_version', '')
return get(serverInfo.value, 'Server.redis_version', '')
})
const redisMode = computed(() => {
return get(props.info, 'Server.redis_mode', '')
return get(serverInfo.value, 'Server.redis_mode', '')
})
const role = computed(() => {
return get(props.info, 'Replication.role', '')
return get(serverInfo.value, 'Replication.role', '')
})
const timeUnit = ['common.unit_minute', 'common.unit_hour', 'common.unit_day']
const uptime = computed(() => {
let seconds = get(props.info, '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(props.info, '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(() => {
const regex = /^db\d+$/
const result = pickBy(props.info['Keyspace'], (value, key) => {
const result = pickBy(serverInfo.value['Keyspace'], (value, key) => {
return regex.test(key)
})
const nums = mapValues(result, (v) => {
@ -69,121 +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 vertical>
<n-card>
<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
:value="props.autoRefresh"
:loading="props.autoLoading"
@update:value="(v) => emit('update:autoRefresh', v)" />
<n-tooltip>
{{ $t('status.refresh') }}
<template #trigger>
<n-button
circle
size="small"
tertiary
@click="emit('refresh')"
:loading="props.autoLoading">
<template #icon>
<n-icon :component="Refresh" />
</template>
</n-button>
</template>
</n-tooltip>
</n-space>
</template>
<n-spin :show="props.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(props.info, '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')">
<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">&LowerRightArrow;</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="props.loading">
<n-tabs default-value="CPU" placement="left" type="line">
<n-tab-pane v-for="(v, k) in props.info" :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>

Some files were not shown because too many files have changed in this diff Show More