Compare commits

...

545 Commits

Author SHA1 Message Date
hamster1963
e03e6232fb chore: update dependencies in package.json to latest versions 2025-04-23 17:04:27 +08:00
hamster1963
484266666d chore: bump version to 2.9.3 in package.json 2025-04-10 10:39:03 +08:00
hamster1963
fb1a74ec09 style: adjust padding and spacing in ThemeSwitcher component 2025-04-10 10:37:02 +08:00
hamster1963
812397235c chore: update dependencies in package.json to latest versions 2025-04-10 10:35:10 +08:00
hamster1963
3411783401 chore: bump version to 2.9.2 in package.json 2025-04-02 13:30:01 +08:00
hamster1963
1ab1559c20 feat: implement theme switcher with radio buttons and add localization for Traditional Chinese 2025-04-02 13:18:42 +08:00
hamster1963
0837393903 chore: update dependencies in package.json to latest versions 2025-04-02 11:36:46 +08:00
hamster1963
55cec2e055 chore: bump version to 2.9.1 in package.json 2025-03-26 09:04:29 +08:00
hamster1963
14d7f1e416 refactor: replace bcrypt with CryptoJS for password hashing and update dependencies 2025-03-26 09:04:06 +08:00
hamster1963
c267b489e4 chore: bump version to 2.9.0 in package.json 2025-03-25 17:38:44 +08:00
仓鼠
8a1ce73564
refactor: improve Switch component state management and initial rende… (#265)
* refactor: improve Switch component state management and initial render handling

* chore: auto-fix linting and formatting issues
2025-03-25 17:31:05 +08:00
hamster1963
6b273622df chore: update permissions to allow write access for Deploy and auto-fix workflows 2025-03-25 16:57:41 +08:00
hamster1963
80c4500822 chore: update dependencies in package.json 2025-03-25 16:23:45 +08:00
仓鼠
aa14f6045f
chore: update permissions for auto-fix lint and format workflow (#264) 2025-03-25 16:20:33 +08:00
仓鼠
4719c2210e
Potential fix for code scanning alert no. 4: Cache Poisoning via execution of untrusted code (#262)
* Potential fix for code scanning alert no. 4: Cache Poisoning via execution of untrusted code

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* chore: auto-fix linting and formatting issues

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-03-25 16:15:09 +08:00
仓鼠
079ff0be32
chore: add permissions for contents read in Deploy.yml (#263)
* chore: add permissions for contents read in Deploy.yml

* chore: auto-fix linting and formatting issues
2025-03-25 16:14:04 +08:00
仓鼠
38ebfcee44
Merge pull request #261 from hamster1963/alert-autofix-7
Potential fix for code scanning alert no. 7: Use of password hash with insufficient computational effort
2025-03-25 16:07:01 +08:00
仓鼠
365ba91bff chore: add @types/bcrypt 2025-03-25 07:45:25 +00:00
hamster1963
865a5ba8ee chore: auto-fix linting and formatting issues 2025-03-25 07:31:50 +00:00
仓鼠
37adab9208
Potential fix for code scanning alert no. 7: Use of password hash with insufficient computational effort
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-03-25 15:31:27 +08:00
hamster1963
e5a4c2f410 chore: bump version to 2.8.3 2025-03-23 21:37:04 +08:00
hamster1963
ce21c9b774 chore: bump version to 2.8.2 2025-03-23 21:36:38 +08:00
hamster1963
fb1b412015 chore: update caniuse-lite, next, and devDependencies to latest versions 2025-03-23 21:36:01 +08:00
hamster1963
24a079624c chore: update caniuse-lite, cmdk, tailwindcss, and postcss to latest versions 2025-03-16 11:12:31 +08:00
hamster1963
b630505ca3 feat: enhance uptime display to show days and hours 2025-03-16 11:02:09 +08:00
hamster1963
d2ab62efdc docs: update demo site link in README 2025-03-14 10:43:25 +08:00
仓鼠
b5ac8251d9
Merge pull request #256 from hamster1963/boot-time
feat: add boot time display to server details and update localization
2025-03-12 09:34:58 +08:00
hamster1963
0b7a5be3c0 chore: update dependencies to latest versions 2025-03-12 09:31:24 +08:00
hamster1963
0f435ee7c1 feat: add boot time display to server details and update localization 2025-03-12 09:30:01 +08:00
hamster1963
ac15be6e71 chore: bump version to 2.8.1 2025-02-28 09:32:36 +08:00
hamster1963
b1bcd7227d feat: update dev script to use turbopack for improved performance 2025-02-28 09:32:20 +08:00
hamster1963
29b545706e fix: ensure unique keys for map features by including index in key prop 2025-02-28 09:32:15 +08:00
hamster1963
8bb5ea0cf7 feat: add loading skeleton for time display in overview section 2025-02-28 09:21:43 +08:00
hamster1963
28d1399b2f chore: update dependencies 2025-02-27 11:37:58 +08:00
hamster1963
b456cad3d5 feat: enhance accessibility and improve component styles across the application 2025-02-22 15:56:04 +08:00
hamster1963
2302a50484 chore: bump version to 2.8.0 2025-02-21 11:35:42 +08:00
hamster1963
6c3c18fa1e chore: update bun lockfile 2025-02-21 11:26:43 +08:00
hamster1963
fca3a9ab52 feat: add server ID and improve interactive map and tooltip functionality 2025-02-21 11:26:32 +08:00
hamster1963
5b8be03765 feat: update button styles in ServerListClient for improved accessibility and visual consistency 2025-02-21 11:05:45 +08:00
hamster1963
1dae136edb chore: update country-flag-icons, next-intl, tailwindcss, postcss dependencies 2025-02-20 09:47:33 +08:00
hamster1963
f49ca034cb feat: enhance network chart x-axis tick rendering with dynamic time interval logic 2025-02-19 17:45:52 +08:00
hamster1963
e8530956fa feat: replace requestAnimationFrame with setInterval for time updates 2025-02-19 15:06:18 +08:00
hamster1963
4171829a15 feat: optimize time display rendering with requestAnimationFrame 2025-02-19 14:26:22 +08:00
hamster1963
8fbe50fd8d chore: update project dependencies 2025-02-18 18:04:38 +08:00
hamster1963
e2a9357f18 feat: add AnimatedCount to server overview metrics 2025-02-18 18:04:19 +08:00
hamster1963
69ff5365d5 feat: replace NumberFlow with custom AnimatedCount component for time display 2025-02-18 17:57:18 +08:00
hamster1963
084f71e4a6 chore: bump version to 2.7.2 2025-02-16 16:03:49 +08:00
hamster1963
cc810dd41c chore: update @types/node to version 22.13.4 2025-02-16 15:57:14 +08:00
hamster1963
c14f371bc2 feat: improve network chart x-axis tick formatting and display 2025-02-16 15:54:40 +08:00
hamster1963
23a3261251 chore: bump version to 2.7.1 2025-02-13 14:59:00 +08:00
hamster1963
a6aa91d35a fix: add locale dependency to component rendering effects 2025-02-13 14:56:56 +08:00
hamster1963
e55a60c12e fix: resolve server-side rendering issue with Mac detection in Footer 2025-02-13 11:50:25 +08:00
hamster1963
2164321e72 chore: update project dependencies to latest versions 2025-02-13 11:35:09 +08:00
hamster1963
b5658d81ab feat: add platform-specific keyboard shortcut display in Footer 2025-02-13 11:34:52 +08:00
hamster1963
79ba408d9f chore: bump version to 2.7.0 2025-02-07 14:37:23 +08:00
hamster1963
98eb515144 chore: update dependencies and optimize import sorting 2025-02-07 14:34:22 +08:00
hamster1963
e8302c7667 refactor: remove environment variable warning logs 2025-02-07 14:33:51 +08:00
hamster1963
4bbda14bf0 feat: enhance auth secret generation with CryptoJS MD5 hashing 2025-02-07 14:32:57 +08:00
hamster1963
e90914b320 refactor: improve SignIn form layout and centering 2025-02-07 01:33:29 +08:00
hamster1963
fc86e904aa refactor: improve code formatting in Footer and ServerListClient components 2025-02-07 01:33:20 +08:00
hamster1963
7277e44f85 chore: bump version to 2.6.3 2025-02-06 00:53:25 +08:00
hamster1963
649a3546f1 chore: update SWR library to version 2.3.1 2025-02-06 00:53:11 +08:00
hamster1963
5e2cad71db chore: enable Next.js experimental performance optimizations 2025-02-06 00:44:29 +08:00
hamster1963
07afc44eb7 refactor: modularize ServerListClient with utility functions and components 2025-02-06 00:26:20 +08:00
hamster1963
1713189333 refactor: improve Footer component with modular link styling and constants 2025-02-06 00:20:05 +08:00
hamster1963
3ce131419f refactor: optimize Header component with memoization and time tracking 2025-02-06 00:15:37 +08:00
hamster1963
010cfce1c4 refactor: improve server details page with type safety and dynamic tab rendering 2025-02-06 00:10:43 +08:00
hamster1963
bf959fbb9a bump version to 2.6.2 2025-02-05 01:24:01 +08:00
hamster1963
d9b83b9c34 chore: update project dependencies to latest versions 2025-02-05 01:23:51 +08:00
hamster1963
9fc050c2a1 refactor: simplify seconds display in Overview time component 2025-02-05 01:23:22 +08:00
hamster1963
c520d415e8 chore: enable React Compiler in Next.js configuration 2025-02-02 21:33:48 +08:00
hamster1963
9731eeb530 bump version to 2.6.1 2025-02-01 19:54:43 +08:00
hamster1963
77ac6f2f44 refactor: reposition DashCommand component in MainLayout 2025-02-01 19:52:48 +08:00
hamster1963
d715d050d2 chore: update dev dependencies to latest versions 2025-02-01 19:47:17 +08:00
hamster1963
e7f947a692 bump version to 2.6.0 2025-01-30 01:05:17 +08:00
hamster1963
6f36d49e00 chore: update cmdk and lucide-react to latest versions 2025-01-30 01:05:02 +08:00
hamster1963
56ddf847a0 style: remove wave emoji from Overview section in localization files 2025-01-30 01:02:42 +08:00
hamster1963
4da2eca1c1 refactor: modify getEnv to return undefined for non-existent environment variables 2025-01-30 00:48:48 +08:00
hamster1963
d775cde3ca refactor: improve environment variable retrieval with optional return type 2025-01-30 00:44:57 +08:00
hamster1963
c59b381a35 feat: add site password configuration support 2025-01-30 00:35:49 +08:00
hamster1963
5cd8d1d92e chore: update dependencies to latest versions 2025-01-30 00:29:02 +08:00
hamster1963
b74e7827d3 feat: enhance time display with NumberFlow component 2025-01-30 00:28:43 +08:00
hamster1963
43fb66552e style: enhance server card hover effects with border and shadow 2025-01-29 23:58:25 +08:00
hamster1963
25bd7c41b4 refactor: improve environment variable handling with type-safe configuration 2025-01-29 23:32:51 +08:00
hamster1963
f4a400522a bump version to 2.5.1 2025-01-28 16:09:40 +08:00
hamster1963
bbee98b19e fix: replace paragraph with div in chart tooltip 2025-01-28 16:08:42 +08:00
hamster1963
361a0ecffd chore: deps 2025-01-28 16:07:34 +08:00
hamster1963
ac23b6a77e style: refine command dialog border and input styling 2025-01-28 16:06:17 +08:00
hamster1963
c4196eec83 bump version to 2.5.0 2025-01-25 12:04:50 +08:00
hamster1963
f7b081fad4 refactor: update server fetch and config with minor optimizations 2025-01-25 12:00:16 +08:00
hamster1963
527bf45f43 feat: update LanguageSwitcher with LanguageIcon 2025-01-25 11:33:01 +08:00
hamster1963
9f6fa51c5a fix: remove unused script 2025-01-25 11:07:54 +08:00
hamster1963
4a3c88c681 perf: remove eslint deps 2025-01-25 11:06:58 +08:00
hamster1963
1b00cd7bf0 chore: deps 2025-01-25 11:03:17 +08:00
hamster1963
d7f9e378fe style: better network tooltip 2025-01-25 10:57:16 +08:00
hamster1963
ea7b8969ee v2.4.0 2025-01-24 00:13:33 +08:00
hamster1963
3ca6a8e310 feat: footer add kdb 2025-01-24 00:13:10 +08:00
hamster1963
f69ec0a010 feat: dash command 2025-01-23 23:49:24 +08:00
hamster1963
a4c0ab7e07 chore: bump tailwindcss to 4.0.0 2025-01-23 10:04:04 +08:00
hamster1963
fe18404f4e v2.3.0 2025-01-21 10:40:10 +08:00
hamster1963
f9f57e4d19 perf: fully optimize 2025-01-21 10:39:52 +08:00
hamster1963
646354e515 refactor: biome config 2025-01-21 10:31:54 +08:00
hamster1963
a8f4c8564f perf: remove unused imports 2025-01-21 10:08:02 +08:00
hamster1963
b4c3bccace chore: deps 2025-01-21 10:04:45 +08:00
hamster1963
b76ab55cb2 refactor: core lib 2025-01-21 10:02:23 +08:00
hamster1963
bc0886e8c0 v2.2.0 2025-01-12 22:58:42 +08:00
hamster1963
957c679a90 chore: deps 2025-01-12 22:57:31 +08:00
hamster1963
0dd8bf7bb7 feat: show last active 2025-01-12 22:56:37 +08:00
hamster1963
fbc4eda27d style: button add hover animation 2025-01-11 18:53:54 +08:00
hamster1963
b265b68fa3 chore: deps 2025-01-11 18:47:56 +08:00
hamster1963
0e1f59bb30 v2.1.3 2025-01-09 00:57:48 +08:00
hamster1963
95525f0adb chore: bump nextjs version 2025-01-09 00:57:10 +08:00
hamster1963
aa9ea7b763 fix: enLocale import 2025-01-09 00:55:09 +08:00
hamster1963
f38d3a192b chore: deps 2025-01-06 21:14:49 +08:00
hamster1963
6ff8b275e7 fix: tag switch gap 2025-01-06 21:13:08 +08:00
hamster1963
36c7316a7f v2.1.2 2025-01-05 22:06:12 +08:00
hamster1963
a5ed28c0f1 feat: use full region name 2025-01-05 22:05:57 +08:00
hamster1963
51689d482e v2.1.1 2025-01-04 00:41:07 +08:00
hamster1963
d8b55564cb chore: deps 2025-01-04 00:40:54 +08:00
hamster1963
35457f8a17 style: tag hover animation 2025-01-04 00:40:34 +08:00
hamster1963
8228f15125 style: header hover animation 2025-01-04 00:38:40 +08:00
hamster1963
cc97147270 style: dropdown text display 2025-01-04 00:28:50 +08:00
hamster1963
6bc7e0de0e fix: use MAX_HISTORY_LENGTH config 2025-01-04 00:21:42 +08:00
hamster1963
6ba7747dd6 fix: lint 2025-01-04 00:12:52 +08:00
hamster1963
15086d054a chore: deps 2025-01-04 00:00:39 +08:00
hamster1963
6979139c98 fix: remove used code 2025-01-04 00:00:11 +08:00
hamster1963
68b7034db6 v2.1.0 happy new year! 2024-12-31 23:45:52 +08:00
hamster1963
2bc608e332 feat: refactor now time 2024-12-31 23:45:26 +08:00
hamster1963
c5b8695a82 fix: auto scroll 2024-12-31 23:26:25 +08:00
hamster1963
0de53d8888 chore: deps 2024-12-31 17:59:50 +08:00
hamster1963
297dd53dd2 fix: remove wrong comment 2024-12-31 17:52:31 +08:00
hamster1963
70b328b8d7 perf: refactor animate 2024-12-31 17:51:32 +08:00
hamster1963
a07a70e965 fix(overview): bandwidth display 2024-12-27 16:41:15 +08:00
hamster1963
39c1eca1b0 docs: new image 2024-12-26 16:28:44 +08:00
hamster1963
83122ad867 fix: chart 2024-12-26 15:17:03 +08:00
hamster1963
93a90f4a33 v2.0.0 2024-12-26 14:42:57 +08:00
hamster1963
8a93bda5b6 feat: use biome 2024-12-26 14:36:51 +08:00
hamster1963
8575aee27e style: DropdownMenuItem rounded 2024-12-26 14:09:21 +08:00
hamster1963
4c7ffb509c feat: loading text 2024-12-26 14:01:53 +08:00
hamster1963
2a44cc7afb chore: deps 2024-12-26 13:48:37 +08:00
hamster1963
72472f7cc1 feat: detail chart history point 2024-12-26 13:48:19 +08:00
hamster1963
a9a7a367c0 fix: set dedupingInterval to 500 2024-12-26 13:22:01 +08:00
hamster1963
6b52a8dedb perf: refactor server data fetch 2024-12-26 13:20:49 +08:00
hamster1963
0c3479bb3b chore: deps 2024-12-24 11:48:57 +08:00
hamster1963
114a6f95eb feat: uptime days unit 2024-12-24 11:48:29 +08:00
hamster1963
682e4ccfe0 feat: offline card link 2024-12-24 11:42:59 +08:00
hamster1963
14b12bf8e0 v1.9.0 2024-12-23 11:31:28 +08:00
hamster1963
80a58d8cfc fix: emoji flag priority 2024-12-23 11:24:24 +08:00
hamster1963
9ff5913ffb fix: remove flag transform 2024-12-23 11:18:14 +08:00
hamster1963
4c5a012bdc chore: deps 2024-12-23 11:17:34 +08:00
hamster1963
c212c6688c v1.8.6 2024-12-20 20:36:54 +08:00
hamster1963
b7ed4b8a4b fix: custom links overflow on mobile 2024-12-20 20:36:43 +08:00
hamster1963
eebda376ca chore: deps 2024-12-20 20:32:57 +08:00
hamster1963
474ed06445 fix: relax the criteria for determining online status 2024-12-20 20:30:15 +08:00
hamster1963
83942483cb v1.8.5 2024-12-20 10:22:26 +08:00
hamster1963
f72ea3079b chore: deps 2024-12-20 10:22:13 +08:00
hamster1963
1ec186faf7 fix: network overview contains offline server 2024-12-20 10:21:48 +08:00
hamster1963
2f058d8777 v1.8.4 2024-12-18 17:38:50 +08:00
hamster1963
24d038b914 feat: refactor peak cut 2024-12-18 17:38:32 +08:00
hamster1963
5f7892c837 v1.8.3 2024-12-18 15:48:51 +08:00
hamster1963
0ac6235422 fix: overview bandwidth overflow on mobile 2024-12-18 15:48:24 +08:00
hamster1963
a19da63fce chore: deps 2024-12-18 15:38:22 +08:00
hamster1963
b21f4288a7 v1.8.2 2024-12-16 10:06:06 +08:00
hamster1963
6e6a3315b8 fix: not-found page height 2024-12-16 10:04:50 +08:00
hamster1963
4de49cb3ab fix: refactor main page 2024-12-16 09:39:48 +08:00
hamster1963
95530243a4 chore: deps 2024-12-16 09:29:00 +08:00
hamster1963
6f16d4b4eb fix: overview image z-index 2024-12-16 09:28:53 +08:00
hamster1963
583a52cced fix: overview network overflow on mobile 2024-12-15 01:30:30 +08:00
仓鼠
406b7ef37f
Merge pull request #186 from hamster1963/pr-dev
test: auto lint
2024-12-13 15:40:48 +08:00
hamster1963
f6e0654bec fix: prettier config 2024-12-13 15:39:34 +08:00
hamster1963
01ac9403e9 fix: eslint error 2024-12-13 15:37:04 +08:00
hamster1963
74418c6ae5 fix: use js config 2024-12-13 15:33:28 +08:00
hamster1963
ecd4688ec9 test: ues next lint 2024-12-13 15:29:50 +08:00
hamster1963
2b918ef915 Merge branch 'main' into pr-dev 2024-12-13 15:28:43 +08:00
hamster1963
533d73c9c8 fix: remove .eslintrc.json 2024-12-13 15:28:15 +08:00
hamster1963
6529ed1b8d fix: build 2024-12-13 15:25:24 +08:00
hamster1963
4cc4cf98d6 fix: build 2024-12-13 15:23:37 +08:00
hamster1963
d26490d644 chore: auto-fix linting and formatting issues 2024-12-13 07:22:22 +00:00
hamster1963
124966c27c test: auto lint 2024-12-13 15:21:38 +08:00
hamster1963
408cd9ce4b fix: lint 2024-12-13 15:19:39 +08:00
hamster1963
55cc033cf5 feat: pr lint 2024-12-13 15:09:12 +08:00
hamster1963
17946d5f0b v1.8.1 2024-12-13 10:39:31 +08:00
hamster1963
3ddf688229 fix: build ignore eslint error 2024-12-13 10:38:54 +08:00
hamster1963
c083a1bfa8 fix: serverActions allowedOrigins 2024-12-13 10:26:31 +08:00
hamster1963
315b3ee16f fix: peer deps 2024-12-13 10:16:42 +08:00
hamster1963
231a66931b fix: ip info error 2024-12-13 10:11:00 +08:00
hamster1963
d075ab5e0e v1.8.0-fix 2024-12-12 11:22:42 +08:00
hamster1963
1e881f5d9f fix(detail): detail page api dedupingInterval 2024-12-12 11:22:16 +08:00
hamster1963
2b426c8d18 v1.8.0 2024-12-12 11:10:13 +08:00
仓鼠
aafc19872b
Merge pull request #178 from hamster1963/ip-info
Ip info
2024-12-12 11:08:50 +08:00
hamster1963
e0e9faaa24 feat: ip info i18n 2024-12-12 11:00:38 +08:00
hamster1963
33d22fa8b3 feat: ip info 2024-12-12 10:46:37 +08:00
hamster1963
d72cd29446 fix: lint 2024-12-12 09:41:46 +08:00
hamster1963
4036c157c9 feat: use api 2024-12-12 09:39:51 +08:00
hamster1963
d64683a617 fix: path 2024-12-12 00:59:44 +08:00
hamster1963
dfef790884 fix: use fs 2024-12-12 00:52:19 +08:00
hamster1963
8f38cf94d8 fix: get file 2024-12-12 00:45:04 +08:00
hamster1963
b573b62452 feat: get ip info 2024-12-12 00:31:06 +08:00
hamster1963
f28f559a0b fix(ServerList): refreshInterval 2024-12-11 21:55:55 +08:00
hamster1963
7a31071c3d 1.7.6 2024-12-11 16:12:13 +08:00
hamster1963
7200c3ecd1 fix: dynamic import disable ssr 2024-12-11 16:10:32 +08:00
hamster1963
452b045e14 fix(overview): image opacity 2024-12-11 15:58:09 +08:00
hamster1963
50a98911ce fix: nextjs version 2024-12-11 15:57:51 +08:00
hamster1963
4bf4b62ed0 chore: deps 2024-12-11 15:43:25 +08:00
hamster1963
b86bfa20f4 v1.7.5 2024-12-10 00:42:36 +08:00
hamster1963
0806fc09d8 perf: remove dev log 2024-12-10 00:41:22 +08:00
hamster1963
d582027fcf fix(global): server sort 2024-12-10 00:38:35 +08:00
hamster1963
ea385ee699 fix: next canary 2024-12-10 00:24:16 +08:00
hamster1963
b4e4c7908e perf: use inlineCss 2024-12-10 00:19:01 +08:00
hamster1963
8c70cce555 perf: use turbo 2024-12-09 00:49:16 +08:00
hamster1963
841650ba7a v1.7.4 2024-12-07 21:16:18 +08:00
hamster1963
2fc4686d51 perf: use fastly cdn 2024-12-07 21:15:53 +08:00
hamster1963
eddacdd781 fix: tooltip position 2024-12-07 21:15:05 +08:00
hamster1963
39145adeb4 fix: lint 2024-12-07 19:28:31 +08:00
hamster1963
08b6c54a68 v1.7.3 2024-12-07 19:25:58 +08:00
hamster1963
ba6186aaba feat(global): new tooltip 2024-12-07 19:25:36 +08:00
hamster1963
4135a4fd77 chore: deps 2024-12-07 18:42:25 +08:00
hamster1963
6559774b1a v1.7.2 2024-12-06 15:15:55 +08:00
hamster1963
c0fcbe15e6 feat: chart peak cut switch 2024-12-06 15:15:43 +08:00
hamster1963
59ad0c805b v1.7.1 2024-12-06 10:41:01 +08:00
hamster1963
2afe8b5e75 fix: inline card mobile display 2024-12-06 10:40:47 +08:00
hamster1963
b2e5c80c07 chore: deps 2024-12-06 10:35:33 +08:00
hamster1963
017a828591 v1.7.0 2024-12-05 15:08:14 +08:00
hamster1963
0b93cf7af8 feat: show map in main page 2024-12-05 15:07:20 +08:00
hamster1963
f8bfbde9b5 perf: refactor global map as client side render 2024-12-05 14:45:58 +08:00
hamster1963
07f02f5da8 chore: deps 2024-12-05 14:31:02 +08:00
hamster1963
ec1c72e4cb fix(overview): refreshInterval 2024-12-05 14:29:53 +08:00
仓鼠
ace9c2ee8c
docs: Update README.md 2024-12-05 11:23:57 +08:00
hamster1963
e51ec0866a v1.6.11 2024-12-04 18:06:43 +08:00
hamster1963
310e7d4338 style: network display 2024-12-04 18:06:28 +08:00
hamster1963
8042edaeff fix: scroll behavior 2024-12-04 17:50:16 +08:00
hamster1963
b21e9d12d5 v1.6.10 2024-12-04 14:38:23 +08:00
hamster1963
89adcd98da style: hide unknown field 2024-12-04 14:37:53 +08:00
仓鼠
da3e1c7086
docs: Update README.md 2024-12-04 11:16:58 +08:00
仓鼠
63b9c952a0
docs: Update LICENSE 2024-12-04 11:12:13 +08:00
hamster1963
99654855cf v1.6.9 2024-12-03 21:50:00 +08:00
hamster1963
6c99ed6983 style(overview): network 2024-12-03 21:49:42 +08:00
hamster1963
41ccd7eeca fix(chart): button text overflow 2024-12-03 21:10:55 +08:00
hamster1963
ac5c7e801d update: v1.6.8 2024-12-03 18:27:36 +08:00
hamster1963
950a20f60c fix: sort 2024-12-03 18:27:08 +08:00
hamster1963
1f4c58457d update: v1.6.7-fix 2024-12-03 17:12:28 +08:00
hamster1963
c40e3706cd style(overview): speed text 2024-12-03 17:12:05 +08:00
hamster1963
3671da354b update: v1.6.7 2024-12-03 16:25:38 +08:00
hamster1963
ccc875fb04 refactor(overview): sort by speed 2024-12-03 16:24:12 +08:00
hamster1963
34f29420df feat: live speed 2024-12-03 16:15:55 +08:00
hamster1963
c771162e0b update: v1.6.6 2024-12-03 11:29:27 +08:00
hamster1963
0cd78f230f fix: pwa color 2024-12-03 11:18:01 +08:00
hamster1963
d258f71beb fix: changelog 2024-12-03 10:24:55 +08:00
hamster1963
a32c485cc5 update: v1.6.5 2024-12-03 10:19:44 +08:00
hamster1963
5e7a345fd9 fix: bun.lockb 2024-12-03 10:19:26 +08:00
hamster1963
3ce9e6cdfc perf: changelog 2024-12-03 10:18:34 +08:00
hamster1963
6ab257afdf chore: deps 2024-12-03 09:47:38 +08:00
hamster1963
a387773697 fix: theme color 2024-12-03 09:45:55 +08:00
仓鼠
848cf32b73
style: NetworkChart button 2024-12-02 22:43:42 +08:00
hamster1963
367ed9baa9 update: v1.6.4 2024-12-02 22:28:06 +08:00
hamster1963
fea435cd47 fix: chart button 2024-12-02 22:27:39 +08:00
hamster1963
6844bea7be update: v1.6.3 2024-12-02 14:15:49 +08:00
hamster1963
3a88c9a837 chore: deps 2024-12-02 14:15:31 +08:00
hamster1963
b4e68180ef feat(detail): add gpu info 2024-12-02 14:14:41 +08:00
仓鼠
14abc76fbc
docs: Update README.md 2024-12-01 22:01:49 +08:00
仓鼠
9a621e3e17
docs: 更新 README.md 2024-12-01 21:59:28 +08:00
hamster1963
9e7ecc4fa4 update: v1.6.2-fix 2024-12-01 21:52:55 +08:00
hamster1963
e74e881a65 fix(detail): load toFixed 2024-12-01 21:52:39 +08:00
hamster1963
42b60e6c5b update: v1.6.2 2024-12-01 19:05:53 +08:00
hamster1963
91f3cf8e2c fix: lockb 2024-12-01 19:05:38 +08:00
hamster1963
70038f9044 style: better inline card 2024-12-01 19:05:08 +08:00
hamster1963
6980b6e6e8 update: v1.6.1-fix 2024-12-01 16:56:37 +08:00
hamster1963
2c4cf3aebe fix(detail): unknown text 2024-12-01 16:56:01 +08:00
hamster1963
7f4ae91204 update: v1.6.1 2024-12-01 16:17:51 +08:00
hamster1963
391e356f7c feat(detail): add chart info 2024-12-01 16:17:34 +08:00
hamster1963
671e31c148 feat(detail): add Load info 2024-12-01 15:57:55 +08:00
hamster1963
7970582f8c update: v1.6.0-fix 2024-12-01 12:00:38 +08:00
hamster1963
61e90d253c fix(inline): use localStorage 2024-12-01 12:00:20 +08:00
hamster1963
b3ef5e1f95 update: v1.6.0 2024-12-01 11:41:52 +08:00
仓鼠
4ffe7a5056
Merge pull request #143 from hamster1963/card-inline
feat: card inline
2024-12-01 11:41:18 +08:00
hamster1963
ff52d52f11 fix: build 2024-12-01 11:37:12 +08:00
hamster1963
c4d801a847 feat: card inline 2024-12-01 11:35:38 +08:00
hamster1963
a7a3228ac1 update: v1.5.7 2024-11-30 23:37:21 +08:00
hamster1963
4eb9cfe218 chore: deps 2024-11-30 23:36:56 +08:00
hamster1963
548c17802e feat(detail): net transfer info 2024-11-30 23:05:22 +08:00
hamster1963
4b813073b0 docs: issue template 2024-11-30 21:48:28 +08:00
hamster1963
7107e22ddd update: v1.5.6-fix 2024-11-29 21:54:12 +08:00
hamster1963
05c8b17abc fix: cache 2024-11-29 21:53:47 +08:00
仓鼠
2d4b32491a
docs: update README.md 2024-11-29 10:09:42 +08:00
仓鼠
1b13bb665a
docs: update README.md 2024-11-29 10:09:02 +08:00
hamster1963
d443021d27 update: v1.5.6 2024-11-28 17:55:02 +08:00
hamster1963
6788aab22e style: better map 2024-11-28 17:54:45 +08:00
hamster1963
db3338c159 update: v1.5.5 2024-11-28 16:07:36 +08:00
hamster1963
16d9210c5c feat(global): add some geo support 2024-11-28 16:07:18 +08:00
hamster1963
a57f19fe2a update: v1.5.4 2024-11-28 11:47:19 +08:00
hamster1963
02f7f7b53a docs: better not found 2024-11-28 11:47:00 +08:00
hamster1963
e3e1d3176c fix(footer): hover color 2024-11-28 11:29:08 +08:00
hamster1963
711ed79dc5 fix: v1.5.3-fix 2024-11-27 21:25:04 +08:00
hamster1963
e758ae137b fixL refresh 404 error 2024-11-27 21:24:48 +08:00
hamster1963
1e7e4c6678 fix: lint 2024-11-27 20:54:17 +08:00
hamster1963
a75aa209f2 update: v1.5.3 2024-11-27 20:47:12 +08:00
hamster1963
1b1f565e2c refactor!: not-found 2024-11-27 20:46:49 +08:00
仓鼠
56052d35b8
docs: create LICENSE 2024-11-27 17:36:03 +08:00
仓鼠
504a725864
docs: update README.md 2024-11-27 15:40:38 +08:00
hamster1963
3d5dd6d077 update: v1.5.2-fix 2024-11-27 15:35:23 +08:00
hamster1963
480c4a42d0 chore: deps 2024-11-27 15:34:39 +08:00
hamster1963
fc5761b50c fix(build): bun.lockb 2024-11-27 15:33:33 +08:00
hamster1963
4d0d371ab3 fix(build): vercel version 2024-11-27 15:33:15 +08:00
仓鼠
80fd6aff9f
docs: update README.md 2024-11-27 15:11:56 +08:00
仓鼠
cabf63a99f
docs: update README.md 2024-11-27 15:11:35 +08:00
仓鼠
f1eecd04c6
docs: update README.md 2024-11-27 10:18:37 +08:00
hamster1963
09b1a4b052 update: v1.5.2 2024-11-26 18:02:19 +08:00
hamster1963
f7dd9c8965 fix(global): use iso_a2_eh 2024-11-26 18:02:05 +08:00
hamster1963
b3d2e09fa1 update: v1.5.1-fix 2024-11-26 17:51:12 +08:00
hamster1963
2cd1a91ab8 fix(global): use iso_a3_eh 2024-11-26 17:50:49 +08:00
hamster1963
86c6de0cb5 update: v1.5.1 2024-11-25 22:15:34 +08:00
hamster1963
24f19bfed9 fix(chart): remove YAxis mirrow 2024-11-25 22:14:31 +08:00
hamster1963
252c64009c fix(global): hover color 2024-11-25 21:57:42 +08:00
hamster1963
dba1528116 feat(global): use geoEquirectangular 2024-11-25 21:55:22 +08:00
仓鼠
1306dbde80
fix: remove mobile tooltip 2024-11-25 18:36:36 +08:00
仓鼠
2e0c05f474
fix(global): tooltip animation 2024-11-25 18:30:04 +08:00
hamster1963
e90b9cf4d4 fix: remove dev code 2024-11-25 18:09:19 +08:00
hamster1963
e7851e1d13 update: v1.5.0 2024-11-25 18:06:28 +08:00
hamster1963
14f7df00c4 chore: remove unused dependencies 2024-11-25 18:06:09 +08:00
hamster1963
e6cd4daa57 feat(global): tooltip animation 2024-11-25 18:03:35 +08:00
hamster1963
f043f5e3a9 feat(global): tooltip 2024-11-25 17:51:01 +08:00
hamster1963
32df026c80 feat: use geoEqualEarth 2024-11-25 17:10:07 +08:00
hamster1963
962a5cd4be fix(header): button hover color on dark mode 2024-11-25 15:34:21 +08:00
hamster1963
22a0b03a98 chore: deps 2024-11-25 15:33:53 +08:00
hamster1963
0b061d805e fix(header): logo path 2024-11-25 15:16:40 +08:00
hamster1963
cb278ec742 fix: NEXT_PUBLIC_Links env example 2024-11-25 11:46:24 +08:00
hamster1963
5878b272be update: v1.4.10 2024-11-24 22:17:20 +08:00
hamster1963
cbdbf6227c chore: deps 2024-11-24 22:15:32 +08:00
hamster1963
88a7a2bba7 style(header): button hover color 2024-11-24 22:15:05 +08:00
hamster1963
8f673497ba style: card hover color 2024-11-24 22:12:03 +08:00
hamster1963
9738ba97c1 update: v1.4.9 2024-11-23 15:50:13 +08:00
hamster1963
87b8651adb chore: deps 2024-11-23 15:49:13 +08:00
仓鼠
f4645d956e
fix: Dockerfile 2024-11-23 14:29:47 +08:00
hamster1963
97cb19f820 update: use node runtime 2024-11-23 14:03:13 +08:00
hamster1963
8f5fc7ef71 fix(docker): use node as runtime 2024-11-23 14:02:46 +08:00
hamster1963
f66758bc03 update: v1.4.8 2024-11-23 00:12:30 +08:00
hamster1963
ce82c67f88 style(global): colorful button 2024-11-23 00:12:08 +08:00
hamster1963
70e748f249 style(footer): decoration hover animation 2024-11-23 00:11:50 +08:00
hamster1963
585770f33f feat(icon): new nezhadash icon 2024-11-22 23:43:58 +08:00
hamster1963
d06e1017d6 docs(env): remove NEXT_PUBLIC_BASE_PATH 2024-11-22 17:58:49 +08:00
hamster1963
d80a01caf8 update: v1.4.7 2024-11-22 17:57:06 +08:00
hamster1963
7eee205f51 fix(revert): remove basePath env 2024-11-22 17:56:50 +08:00
hamster1963
1b125ce0f8 update: v1.4.6-fix 2024-11-22 17:33:17 +08:00
hamster1963
6c7967fc9c fix(header): time opacity 2024-11-22 17:32:57 +08:00
hamster1963
407f0e5ccc update: v1.4.6 2024-11-22 10:12:58 +08:00
hamster1963
d63f5923ea perf(global): use larger spacing to avoid high CPU usage 2024-11-22 10:12:38 +08:00
仓鼠
5e51fb3d02
Merge pull request #121 from hamster1963/tailwind-v4
feat: Tailwind v4
2024-11-22 10:05:57 +08:00
hamster1963
01b3b11bc5 fix(global): turf spacing 2024-11-22 10:00:28 +08:00
hamster1963
60f6df50af fix(signin): border color 2024-11-22 09:58:53 +08:00
hamster1963
a4f641a3dd fix(networkChart): border color 2024-11-22 09:50:53 +08:00
hamster1963
16da9c3f0f fix(global): back button cursor 2024-11-22 09:39:49 +08:00
hamster1963
005b80360c chore(deps): bump tailwindcss to v4 2024-11-22 09:39:12 +08:00
hamster1963
fc6c367f97 update: v1.4.5 2024-11-21 15:39:08 +08:00
hamster1963
e5e2d2b02d fix(global): remove dev map code 2024-11-21 15:38:41 +08:00
hamster1963
b8d6e83169 chore(deps): caniuse-lite 2024-11-21 15:33:51 +08:00
hamster1963
62cdb7e18c fix(global): the map of Russia not being fully populated 2024-11-21 15:26:14 +08:00
hamster1963
7e1a818dc2 feat: new global map 2024-11-21 15:17:22 +08:00
hamster1963
6a0367c192 update: v1.4.4-fix 2024-11-21 10:26:06 +08:00
hamster1963
3551bc90cd fix: build error 2024-11-21 10:25:37 +08:00
hamster1963
4133a7f5c7 update: v1.4.4 2024-11-21 10:20:26 +08:00
hamster1963
753c7dec10 fix(global): disable overview click event 2024-11-21 10:20:06 +08:00
hamster1963
722ec67091 perf(global): better ux 2024-11-21 10:09:09 +08:00
hamster1963
d8312642e4 update: v1.4.3-fix 2024-11-21 01:00:30 +08:00
hamster1963
5eddce9624 fix: better geo-json 2024-11-21 01:00:05 +08:00
hamster1963
e49a4b5b2a fix(global): remove console 2024-11-20 23:54:07 +08:00
hamster1963
1c345d16a9 update: v1.4.3 2024-11-20 23:48:54 +08:00
hamster1963
930f0416e7 fix(global): fallback display points 2024-11-20 23:48:34 +08:00
hamster1963
cfb9d0b49f perf(global): better refined maps 2024-11-20 23:47:53 +08:00
hamster1963
8d6bcbcccd docs: new images 2024-11-20 18:05:43 +08:00
hamster1963
ffe36efccc update: v1.4.2-fix 2024-11-20 17:42:17 +08:00
hamster1963
9e3945efff fix(global): point radius 2024-11-20 17:41:59 +08:00
hamster1963
635cc47176 update: v1.4.2 2024-11-20 17:33:15 +08:00
hamster1963
4a2971e309 feat(overview): sort by network traffic 2024-11-20 17:32:34 +08:00
hamster1963
20983bb692 feat(global): light up the world! 2024-11-20 17:03:32 +08:00
hamster1963
30656c192e update: v1.4.1 2024-11-20 10:06:59 +08:00
hamster1963
687b75d6fe fix(ServerList): fixed top layout in which the name may be line-breaking 2024-11-20 10:05:47 +08:00
hamster1963
461dcd0b52 perf(global): precompute the map 2024-11-20 09:59:55 +08:00
hamster1963
7a207d559a update: v1.4.0-fix 2024-11-19 23:39:54 +08:00
hamster1963
955244ee7f fix(tag): overflow on mobile 2024-11-19 23:39:31 +08:00
hamster1963
cc12787382 update: v1.4.0 2024-11-19 22:04:36 +08:00
仓鼠
44938a920b
Merge pull request #111 from hamster1963/global
feat: Global map
2024-11-19 21:56:58 +08:00
hamster1963
55aff8563d perf(global): add Suspense 2024-11-19 21:47:05 +08:00
hamster1963
6ed640e9e4 feat: add global map 2024-11-19 21:34:15 +08:00
hamster1963
00d9a86bf6 feat: global 2024-11-19 17:02:47 +08:00
仓鼠
7a5f2e6f6d
docs: Update README.md 2024-11-19 14:52:22 +08:00
hamster1963
f03d145320 update: v1.3.2 2024-11-19 14:36:56 +08:00
hamster1963
271e0c5b60 feat(header): custom links 2024-11-19 14:34:40 +08:00
hamster1963
2b10bd4510 feat: filter servers by status 2024-11-19 12:54:00 +08:00
hamster1963
d83691adb3 update: v1.3.1 2024-11-18 14:24:48 +08:00
hamster1963
5f710c311f fix(revert): add NEXT_PUBLIC_ShowTagCount env 2024-11-18 14:22:01 +08:00
hamster1963
01a9be99bb update: v1.3.0 2024-11-17 22:44:50 +08:00
hamster1963
39ed9f4a86 feat: add basePath env 2024-11-17 22:41:55 +08:00
hamster1963
348519ed5b fix(router): error in the back button after refreshing the first time you enter the detail page 2024-11-15 11:28:46 +08:00
hamster1963
926d45b4be update: v1.2.9 2024-11-14 16:54:53 +08:00
hamster1963
7e1a74c6f0 feat: show number within subgroups 2024-11-14 16:54:10 +08:00
hamster1963
f931a9c487 feat: new dropdown-menu 2024-11-14 16:25:16 +08:00
hamster1963
e6d2916402 chore: deps 2024-11-14 16:24:46 +08:00
hamster1963
34becfe979 update: v1.2.8 2024-11-11 15:14:11 +08:00
hamster1963
b5d0bc0b65 feat(env): add disable index env 2024-11-11 15:10:40 +08:00
hamster1963
9202985fdc chore(deps): bump next-themes to 0.4.3 2024-11-11 15:10:18 +08:00
hamster1963
2ab1fc7710 update: v1.2.7 2024-11-08 11:22:50 +08:00
hamster1963
6a8e14e9bc chore(deps): bump next.js version to 15.0.3 2024-11-08 11:21:43 +08:00
hamster1963
4116e12237 perf: use LazyMotion 2024-11-08 11:21:19 +08:00
hamster1963
b9e6cd750d update: v1.2.6 2024-11-07 14:28:50 +08:00
hamster1963
d6be65e321 fix(DetailChart): NaN on detail chart 2024-11-07 14:24:15 +08:00
hamster1963
adaea14bc9 feat(ServerCard): refactor card section when FixedTopServerName is enabled 2024-11-07 14:23:40 +08:00
仓鼠
9854df5199
fix: vercel env 2024-11-06 18:40:58 +08:00
仓鼠
b498b67ea3
fix: vercel env 2024-11-06 18:39:51 +08:00
hamster1963
56d28c1ec1 update: v1.2.5 2024-11-06 16:29:55 +08:00
hamster1963
bc6566a363 fix(cloudflare): get env 2024-11-06 16:23:14 +08:00
仓鼠
d3936ff307
Merge pull request #104 from hamster1963/fix-auth-cache
fix:layout cache
2024-11-06 16:12:20 +08:00
hamster1963
913a905100 perf(serverless): fallback to static env 2024-11-06 15:57:17 +08:00
hamster1963
2462659026 fix(layout): revert client side auth 2024-11-06 15:48:47 +08:00
hamster1963
adaed58fa5 test: client side auth 2024-11-06 15:38:00 +08:00
hamster1963
b123ad9f3e perf(layout): refactor auth check 2024-11-06 15:29:44 +08:00
hamster1963
a8cbf9589a fix(overview): bandwidth text warp 2024-11-05 17:52:40 +08:00
hamster1963
64814fcf40 update: v1.2.4 2024-11-05 17:43:26 +08:00
仓鼠
e485eeda0c
feat(docs): brand new docs site 2024-11-05 17:41:36 +08:00
hamster1963
7d837b6dcc fix(overview): bandwidth show on mobile 2024-11-05 17:36:06 +08:00
hamster1963
2924240d81 update: v1.2.3 2024-11-04 21:45:31 +08:00
hamster1963
732bd660dd feat(ServerCard): show tcp udp on large screen when fixedTopServerName is enable 2024-11-04 21:44:51 +08:00
hamster1963
ffbd3a5aff docs(readme): lint 2024-11-04 21:43:58 +08:00
hamster1963
b284f1e1d2 docs: add changelog config 2024-11-04 21:20:02 +08:00
仓鼠
b85b4b8762
docs: remove demo 2024-11-04 18:29:28 +08:00
hamster1963
2c09f8a59e update: v1.2.2 2024-11-04 14:45:01 +08:00
hamster1963
1876d87c78 doc: add NEXT_PUBLIC_FixedTopServerName env 2024-11-04 14:44:24 +08:00
hamster1963
667ac0f165 feat: add fixedTopServerName env 2024-11-04 14:42:41 +08:00
hamster1963
d8bb86ddd9 feat: refactoring server name typography 2024-11-04 14:41:16 +08:00
hamster1963
5ba82625b2 chore(dpes): rechart 2024-11-03 13:22:02 +08:00
hamster1963
d92d6c997e update: v1.2.1 2024-11-02 23:40:34 +08:00
hamster1963
80c7f6aebe fix: button color 2024-11-02 23:40:14 +08:00
hamster1963
0fffc45f19 feat: new background color 2024-11-02 23:40:01 +08:00
hamster1963
8930cea0c4 fix: server name mask 2024-11-02 23:35:52 +08:00
hamster1963
42c7acabb3 update: v1.2.0 2024-11-02 21:31:33 +08:00
hamster1963
795ced7f38 refactor: server name text no wrap 2024-11-02 21:30:46 +08:00
hamster1963
f42c1d1166 update: v1.1.1 2024-11-01 10:02:13 +08:00
仓鼠
e35f37b87d
Merge pull request #98 from hamster1963/ppr
feat: ppr
2024-11-01 10:01:31 +08:00
hamster1963
510ddc228c feat: ppr 2024-11-01 09:52:53 +08:00
hamster1963
c813c3f6df doc: readme 2024-10-30 17:34:14 +08:00
hamster1963
6c2edce5af fix: unexpected scrollIntoView 2024-10-30 17:34:02 +08:00
hamster1963
92c3894772 update: v1.1.0 2024-10-30 13:41:45 +08:00
仓鼠
d9f7a32503
doc: update README.md 2024-10-30 13:34:22 +08:00
仓鼠
9cd68fd0a1
Merge pull request #96 from hamster1963/next15
chore: Next15
2024-10-30 13:24:34 +08:00
hamster1963
1e156bb5cb fix: detail chart 2024-10-30 13:11:32 +08:00
hamster1963
30e8a6349c fix: eslint 2024-10-30 13:06:35 +08:00
hamster1963
1110b23dbf chore: nextjs15 2024-10-30 12:08:06 +08:00
hamster1963
ccecf219af chore: nextjs15 2024-10-30 12:07:32 +08:00
hamster1963
32e617fd56 fix: manifest info 2024-10-30 11:34:08 +08:00
hamster1963
ea51c7d5f3 fix: G/s compatible 2024-10-25 15:24:20 +08:00
hamster1963
d41db008f5 fix: release note 2024-10-24 22:00:22 +08:00
hamster1963
7f4f8d142a fix: tab switch 2024-10-24 21:51:42 +08:00
hamster1963
260bbb5081 fix: remove unstable_setRequestLocale 2024-10-24 21:42:23 +08:00
hamster1963
23cb4c8cce update: version 1.0.0 2024-10-24 21:39:48 +08:00
仓鼠
b48e467c74
Merge pull request #92 from hamster1963/router-perf
perf: use Link prefetch
2024-10-24 21:36:12 +08:00
hamster1963
3cb64a6e76 fix: defaultTag 2024-10-24 21:26:11 +08:00
hamster1963
ae844d1031 fix: docker 2024-10-24 21:15:53 +08:00
hamster1963
a9c6cd607e refactor: i18n 2024-10-24 21:13:08 +08:00
hamster1963
606237d57e perf: use Link prefetch 2024-10-24 15:33:59 +08:00
hamster1963
3007992af5 update: version 0.8.0 2024-10-24 15:22:43 +08:00
hamster1963
ff02c025fb style: uniform rounded curvature 2024-10-24 15:16:08 +08:00
hamster1963
50221b451c refactor: route and server fetch 2024-10-24 15:14:16 +08:00
hamster1963
472cdce8ba update: version 0.7.5 2024-10-24 14:35:46 +08:00
hamster1963
9ab1cfff9d fix: fetch data empty 2024-10-24 14:35:15 +08:00
hamster1963
1a3bc4650e fix: add server side route error log 2024-10-24 13:54:56 +08:00
hamster1963
fac484bc45 fix: docker fetch error log 2024-10-24 13:47:45 +08:00
hamster1963
3e9a6e1eef fix: add server-side error log 2024-10-24 13:30:49 +08:00
hamster1963
7656bbe319 feat: add version tag 2024-10-24 12:00:27 +08:00
hamster1963
f2211a418c feat: use antfu/changelogithub 2024-10-24 10:41:29 +08:00
hamster1963
0cfebd9773 perf: remove cache mount 2024-10-23 17:28:15 +08:00
hamster1963
e1a2a2dfe1 perf: remove remote cache 2024-10-23 16:42:49 +08:00
仓鼠
ffbd9c5201
Merge pull request #89 from hamster1963/perf-docker-build
perf: docker build
2024-10-23 15:23:48 +08:00
hamster1963
c5e33b08b6 fix: buildkit warning 2024-10-23 15:23:19 +08:00
hamster1963
7e182ed6eb fix: docker build cache 2024-10-23 14:53:41 +08:00
hamster1963
59363fcf0c perf: remvoe qemu 2024-10-23 14:49:58 +08:00
hamster1963
2c6bf553f7 perf: detail page use cache data 2024-10-23 13:51:26 +08:00
hamster1963
be6df3cbed feat: tag switch auto scroll 2024-10-23 12:58:45 +08:00
hamster1963
84a10263f0 perf: use swr isVisible 2024-10-22 22:36:25 +08:00
hamster1963
6fbba1ec2d fix: detail page scroll 2024-10-22 21:34:23 +08:00
hamster1963
11abe9255a fix: hasHistory 2024-10-22 20:51:07 +08:00
hamster1963
1b868a90b5 feat: optimised scroll position 2024-10-22 19:13:47 +08:00
hamster1963
6e8691f9de style: network chart 2024-10-22 17:51:57 +08:00
hamster1963
0729aeffed feat: add switch mouse scroll 2024-10-22 17:46:50 +08:00
hamster1963
b3168750b3 feat: server detail region 2024-10-22 11:34:07 +08:00
仓鼠
4d8ea21feb
Merge pull request #84 from hamster1963/turbo
fix: use turbo
2024-10-22 11:01:58 +08:00
hamster1963
7d23a20043 fix: tab overflow 2024-10-22 10:56:55 +08:00
hamster1963
f65a7a8724 fix: api redirect 2024-10-22 10:53:06 +08:00
hamster1963
d964d668dc fix: use turbo 2024-10-22 10:52:49 +08:00
hamster1963
4b4a5e33a9 fix: use github-hosted-runner 2024-10-21 23:48:17 +08:00
hamster1963
3de1b73ff5 test: use self-hosted runner 2024-10-21 17:26:31 +08:00
仓鼠
71a77f8308
doc: update README.md 2024-10-21 15:25:26 +08:00
hamster1963
ba4ea6ec8f fix: refactor auth callbacks 2024-10-21 14:56:45 +08:00
仓鼠
b6980c9daa
Merge pull request #81 from hamster1963/fix-auth
fix: auth
2024-10-21 13:52:15 +08:00
hamster1963
c94a846c0b fix: error control 2024-10-21 13:49:55 +08:00
hamster1963
117ea33382 perf: remove error state 2024-10-21 13:41:23 +08:00
hamster1963
dd05154be2 feat: client side auth 2024-10-21 13:10:59 +08:00
仓鼠
a7b12bbf64
doc: update README.md 2024-10-21 12:47:23 +08:00
hamster1963
8e5ddd6de8 feat: add success state 2024-10-21 12:32:29 +08:00
仓鼠
17a9b20cda
Merge pull request #80 from hamster1963/fix-docker
refactor: sign-in login
2024-10-21 12:22:52 +08:00
hamster1963
667ae590bb refactor: signin logic 2024-10-21 12:20:50 +08:00
hamster1963
b7085f3d41 feat: add auth env 2024-10-21 11:39:10 +08:00
hamster1963
ca0f12774b fix: docker auth_url 2024-10-21 11:28:44 +08:00
hamster1963
9a0b65a3ab fix: docker callback url 2024-10-21 10:57:13 +08:00
hamster1963
9ad5c1f289 refactor: auth component 2024-10-21 10:32:12 +08:00
hamster1963
f42cc026ac feat: add api auth redirect 2024-10-21 10:25:00 +08:00
仓鼠
f2b47d5cbf
Merge pull request #79 from hamster1963/refactor-auth
refactor: auth page
2024-10-21 10:17:00 +08:00
hamster1963
1a46cec662 refactor: auth page 2024-10-21 10:13:00 +08:00
hamster1963
6bb1a5a5a0 fix: set trustHost 2024-10-21 02:59:20 +08:00
hamster1963
1c2aec7187 fix: env key 2024-10-21 02:34:34 +08:00
hamster1963
1e14031509 doc: new env 2024-10-21 02:27:31 +08:00
仓鼠
a70a6a5645
Merge pull request #78 from hamster1963/auth
feat: add site password
2024-10-21 02:24:55 +08:00
hamster1963
f1369b0a51 fix: cloudflare error 2024-10-21 02:19:30 +08:00
hamster1963
5533ef5fb9 fix: redirect 2024-10-20 23:50:23 +08:00
hamster1963
27d61f6174 fix: login state 2024-10-20 23:27:54 +08:00
hamster1963
aff62d55dc feat: login-in form 2024-10-20 21:50:46 +08:00
hamster1963
b7e403756a fix: empty SITE_PASSWORD 2024-10-20 16:19:42 +08:00
hamster1963
20ed4a819f feat: set api auth 2024-10-20 16:17:33 +08:00
hamster1963
62e0adb97a feat: init auth 2024-10-20 15:42:05 +08:00
hamster1963
adfea0be8e fix: time loading 2024-10-20 13:09:56 +08:00
hamster1963
7bed9cf4ba fix: detail page loading Skeleton 2024-10-20 13:03:14 +08:00
仓鼠
f8032070db
doc: update README.md 2024-10-20 11:33:42 +08:00
仓鼠
5dba593bf0
Merge pull request #77 from hamster1963/new-server-page
feat: merge details and network pages
2024-10-20 01:59:29 +08:00
hamster1963
dff839e0da feat: merge details and network pages 2024-10-20 01:53:47 +08:00
hamster1963
a8358e04e4 fix: init chart 2024-10-20 00:36:30 +08:00
hamster1963
46958194a4 fix: error message 2024-10-20 00:36:22 +08:00
hamster1963
57c7a60860 feat: formatRelativeTime add seconds 2024-10-20 00:36:08 +08:00
hamster1963
3413c73889 fix: better init detail chart 2024-10-20 00:25:00 +08:00
hamster1963
250d0baff8 fix: custome metadata 2024-10-20 00:09:19 +08:00
hamster1963
1c71d37683 fix: overview error 2024-10-19 23:38:44 +08:00
hamster1963
3ac6124653 fix: bun.lockb 2024-10-19 22:06:05 +08:00
仓鼠
293bc0d58e
Merge pull request #68 from hamster1963/detail-page
feat:detail page
2024-10-19 22:00:40 +08:00
hamster1963
9e5a9e2e2d doc: new shot 2024-10-19 21:57:12 +08:00
hamster1963
4040c6d83a feat: separate upload and download traffic display 2024-10-19 21:10:09 +08:00
hamster1963
5f1d84992c feat: detail i18n 2024-10-19 19:27:59 +08:00
hamster1963
6be6e30fd7 fix: router.push 2024-10-19 17:48:47 +08:00
hamster1963
3e6e1f1e8a Merge branch 'main' into detail-page 2024-10-19 12:56:30 +08:00
hamster1963
aa07d49166 feat: add detail charts 2024-10-19 12:52:24 +08:00
hamster1963
b83219bbf2 feat: detail info 2024-10-19 02:09:04 +08:00
hamster1963
dbe67ab205 feat: init cpu chart 2024-10-18 18:01:32 +08:00
hamster1963
d7f36ce144 feat: add detail card 2024-10-18 16:46:29 +08:00
hamster1963
7ce2415d75 feat: add detail fetch 2024-10-18 11:48:19 +08:00
hamster1963
9d04dc5889 feat: detail page init 2024-10-18 09:53:00 +08:00
hamster1963
fe20952e7b feat: add detail page 2024-10-17 23:45:18 +08:00
仓鼠
b4dcb94685 chore: deps 2024-10-15 16:09:26 +00:00
仓鼠
78042da151 style: optimized status display 2024-10-15 15:30:26 +00:00
仓鼠
45c12e3399 feat: add header router.push 2024-10-15 15:07:47 +00:00
159 changed files with 7116 additions and 2454 deletions

View File

@ -8,6 +8,11 @@ NEXT_PUBLIC_DisableCartoon=false
NEXT_PUBLIC_ShowTag=true
NEXT_PUBLIC_ShowNetTransfer=false
NEXT_PUBLIC_ForceUseSvgFlag=false
NEXT_PUBLIC_FixedTopServerName=false
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
NEXT_PUBLIC_CustomTitle=NezhaDash
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false
NEXT_PUBLIC_ShowIpInfo=false

19
.github/ISSUE_TEMPLATE/bug-提交.md vendored Normal file
View File

@ -0,0 +1,19 @@
---
name: Bug 提交
about: 提交 bug让面板变得更好。
title: "[BUG]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**描述 bug**
在这里描述 bug 的相关信息
**屏幕截图**
有屏幕截图可以帮助更快定位到问题
**额外信息**
可附上其他需要的额外信息

14
.github/ISSUE_TEMPLATE/功能申请.md vendored Normal file
View File

@ -0,0 +1,14 @@
---
name: 功能申请
about: 描述需求
title: "[FEAT]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**需要什么?**
**额外信息**

14
.github/ISSUE_TEMPLATE/改善建议.md vendored Normal file
View File

@ -0,0 +1,14 @@
---
name: 改善建议
about: 交流面板需要改进的地方
title: "[SUGGEST]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**需要改进的?**
-

BIN
.github/get-token.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

BIN
.github/shot-1.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 KiB

BIN
.github/shot-2.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

BIN
.github/shot-3.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

BIN
.github/shot-4.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 791 KiB

BIN
.github/shotOne.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

BIN
.github/shotTwo.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

BIN
.github/v2-1.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
.github/v2-2.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
.github/v2-3.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
.github/v2-4.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
.github/v2-dark.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@ -1,5 +1,7 @@
name: Build and push Docker image
permissions:
contents: write
on:
push:
tags:
@ -17,12 +19,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
@ -57,30 +60,12 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
changelog:
name: Generate Changelog
runs-on: ubuntu-latest
needs: build-and-push
outputs:
release_body: ${{ steps.git-cliff.outputs.content }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
fetch-depth: 0
- name: Generate a changelog
uses: orhun/git-cliff-action@v4
id: git-cliff
with:
config: git-cliff-config/cliff.toml
args: -vv --latest --strip 'footer'
bun-version: "latest"
- name: Changelog
run: bun x changelogithub
env:
OUTPUT: CHANGES.md
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
body: ${{ steps.git-cliff.outputs.content }}
token: ${{ secrets.GITHUB_TOKEN }}
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@ -0,0 +1,56 @@
name: Auto Fix Lint and Format
permissions:
contents: write
pull-requests: write
on:
pull_request:
types: [opened, synchronize]
jobs:
auto-fix:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "latest"
- name: Install dependencies
run: bun install
- name: Run linter & formatter and fix issues
run: bun run check:fix
- name: Check for changes
id: check_changes
run: |
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: auto-fix linting and formatting issues"
commit_options: "--no-verify"
file_pattern: "."
- name: Add PR comment
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Linting and formatting issues were automatically fixed. Please review the changes.'
});

View File

@ -1,4 +1,4 @@
FROM oven/bun:1 AS base
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps
@ -14,7 +14,7 @@ COPY . .
RUN bun run build
# Stage 3: Production image
FROM oven/bun:1-alpine AS runner
FROM node:23-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
@ -22,4 +22,4 @@ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["bun", "run", "server.js"]
CMD ["node", "server.js"]

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,43 +1,38 @@
<h1 align="center">NezhaDash</h1>
<strong>NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</strong>
<div align="center"><img width="600" alt="nezhadash" src="https://github.com/user-attachments/assets/0a5768e1-96f2-4f8a-b77f-01488ed3b237"></div>
<h3 align="center">NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</h3>
<br>
</div>
| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 | 如何更新? |
| ----------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------- |
| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) | [更新教程](https://buycoffee.top/blog/tech/nezha-upgrade) |
| [Vercel-demo](https://nezha-dash-ruddy.vercel.app) | [Docker-demo](https://nezha-docker.buycoffee.tech) | [Cloudflare-demo](https://nezha-cloudflare.buycoffee.tech) |
> [!CAUTION]
> 此为 V0 兼容版本,与 V1 内置版本功能上可能有所不同
>
> V0 | V1 版本 issue 请在当前仓库发起
#### 环境变量
> [!TIP]
> 有关 V1 版本 pr 可移步 https://github.com/hamster1963/nezha-dash-v1
| 变量名 | 含义 | 示例 |
| ------------------------------ | -------------------------------- | ------------------------------------------------------------- |
| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 |
| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi |
| DefaultLocale | 面板默认显示语言(代码参考下表) | **默认**en |
| ForceShowAllServers | 是否强制显示所有服务器 | **默认**false |
| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**2000 |
| NEXT_PUBLIC_ShowFlag | 是否显示旗帜 | **默认**false |
| NEXT_PUBLIC_DisableCartoon | 是否禁用卡通人物 | **默认**false |
| NEXT_PUBLIC_ShowTag | 是否显示标签 | **默认**false |
| NEXT_PUBLIC_ShowNetTransfer | 是否显示流量信息 | **默认**false |
| NEXT_PUBLIC_ForceUseSvgFlag | 是否强制使用SVG旗帜 | **默认**false |
| NEXT_PUBLIC_CustomLogo | 自定义Logo | **示例**https://nezha-cf.buycoffee.top/apple-touch-icon.png |
| NEXT_PUBLIC_CustomTitle | 自定义标题 | |
| NEXT_PUBLIC_CustomDescription | 自定义描述(无多语言支持) | |
### 部署
#### 多语言支持
支持部署环境:
| 语言 | 代码 | 是否完成翻译 |
| -------- | ---- | ------------ |
| 简体中文 | zh | 是 |
| 繁体中文 | zh-t | 是 |
| 英语 | en | 是 |
| 日语 | ja | 是 |
- Vercel
- Cloudflare
- Docker
![screen-shot-one](/.github/shot-1.png)
![screen-shot-two](/.github/shot-2.png)
![screen-shot-three](/.github/shot-3.png)
![screen-shot-four](/.github/shot-4.png)
[演示站点](https://nezha-vercel.vercel.app)
[说明文档](https://nezhadash-docs.vercel.app)
### 如何更新
[更新教程](https://buycoffee.top/blog/tech/nezha-upgrade)
### 环境变量
[环境变量介绍](https://nezhadash-docs.vercel.app/environment)
![screen](/.github/v2-1.webp)
![screen](/.github/v2-2.webp)
![screen](/.github/v2-3.webp)
![screen](/.github/v2-4.webp)
![screen](/.github/v2-dark.webp)

View File

@ -0,0 +1,398 @@
"use client"
import type { NezhaAPIMonitor, ServerMonitorChart } from "@/app/types/nezha-api"
import NetworkChartLoading from "@/components/loading/NetworkChartLoading"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import getEnv from "@/lib/env-entry"
import { formatTime, nezhaFetcher } from "@/lib/utils"
import { useTranslations } from "next-intl"
import * as React from "react"
import { useCallback, useMemo } from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import useSWR from "swr"
interface ResultItem {
created_at: number
[key: string]: number
}
export function NetworkChartClient({
server_id,
show,
}: {
server_id: number
show: boolean
}) {
const t = useTranslations("NetworkChartClient")
const { data, error } = useSWR<NezhaAPIMonitor[]>(
`/api/monitor?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000,
isVisible: () => show,
},
)
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
</div>
<NetworkChartLoading />
</>
)
}
if (!data) return <NetworkChartLoading />
const transformedData = transformData(data)
const formattedData = formatData(data)
const initChartConfig = {
avg_delay: {
label: t("avg_delay"),
},
} satisfies ChartConfig
const chartDataKey = Object.keys(transformedData)
return (
<NetworkChart
chartDataKey={chartDataKey}
chartConfig={initChartConfig}
chartData={transformedData}
serverName={data[0].server_name}
formattedData={formattedData}
/>
)
}
export const NetworkChart = React.memo(function NetworkChart({
chartDataKey,
chartConfig,
chartData,
serverName,
formattedData,
}: {
chartDataKey: string[]
chartConfig: ChartConfig
chartData: ServerMonitorChart
serverName: string
formattedData: ResultItem[]
}) {
const t = useTranslations("NetworkChart")
const defaultChart = "All"
const [activeChart, setActiveChart] = React.useState(defaultChart)
const [isPeakEnabled, setIsPeakEnabled] = React.useState(false)
const handleButtonClick = useCallback(
(chart: string) => {
setActiveChart((prev) => (prev === chart ? defaultChart : chart))
},
[defaultChart],
)
const getColorByIndex = useCallback(
(chart: string) => {
const index = chartDataKey.indexOf(chart)
return `hsl(var(--chart-${(index % 10) + 1}))`
},
[chartDataKey],
)
const chartButtons = useMemo(
() =>
chartDataKey.map((key) => (
<button
type="button"
key={key}
data-active={activeChart === key}
className={
"relative z-30 flex grow basis-0 cursor-pointer flex-col justify-center gap-1 border-neutral-200 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-6 dark:border-neutral-800"
}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-muted-foreground text-xs">{key}</span>
<span className="font-bold text-md leading-none sm:text-lg">
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms
</span>
</button>
)),
[chartDataKey, activeChart, chartData, handleButtonClick],
)
const chartLines = useMemo(() => {
if (activeChart !== defaultChart) {
return (
<Line
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey="avg_delay"
stroke={getColorByIndex(activeChart)}
/>
)
}
return chartDataKey.map((key) => (
<Line
key={key}
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey={key}
stroke={getColorByIndex(key)}
connectNulls={true}
/>
))
}, [activeChart, defaultChart, chartDataKey, getColorByIndex])
const processedData = useMemo(() => {
if (!isPeakEnabled) {
return activeChart === defaultChart ? formattedData : chartData[activeChart]
}
const data = (
activeChart === defaultChart ? formattedData : chartData[activeChart]
) as ResultItem[]
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
const alpha = 0.3 // EWMA平滑因子
// 辅助函数:计算中位数
const getMedian = (arr: number[]) => {
const sorted = [...arr].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// 辅助函数:异常值处理
const processValues = (values: number[]) => {
if (values.length === 0) return null
const median = getMedian(values)
const deviations = values.map((v) => Math.abs(v - median))
const medianDeviation = getMedian(deviations) * 1.4826 // MAD估计器
// 使用中位数绝对偏差(MAD)进行异常值检测
const validValues = values.filter(
(v) =>
Math.abs(v - median) <= 3 * medianDeviation && // 更严格的异常值判定
v <= median * 3, // 限制最大值不超过中位数的3倍
)
if (validValues.length === 0) return median // 如果没有有效值,返回中位数
// 计算EWMA
let ewma = validValues[0]
for (let i = 1; i < validValues.length; i++) {
ewma = alpha * validValues[i] + (1 - alpha) * ewma
}
return ewma
}
// 初始化EWMA历史值
const ewmaHistory: { [key: string]: number } = {}
return data.map((point, index) => {
if (index < windowSize - 1) return point
const window = data.slice(index - windowSize + 1, index + 1)
const smoothed = { ...point } as ResultItem
if (activeChart === defaultChart) {
for (const key of chartDataKey) {
const values = window
.map((w) => w[key])
.filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory[key] === undefined) {
ewmaHistory[key] = processed
} else {
ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key]
}
smoothed[key] = ewmaHistory[key]
}
}
}
} else {
const values = window
.map((w) => w.avg_delay)
.filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory.current === undefined) {
ewmaHistory.current = processed
} else {
ewmaHistory.current = alpha * processed + (1 - alpha) * ewmaHistory.current
}
smoothed.avg_delay = ewmaHistory.current
}
}
}
return smoothed
})
}, [isPeakEnabled, activeChart, formattedData, chartData, chartDataKey, defaultChart])
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("ServerMonitorCount")}
</CardDescription>
<div className="mt-0.5 flex items-center space-x-2">
<Switch id="Peak" checked={isPeakEnabled} onCheckedChange={setIsPeakEnabled} />
<Label className="text-xs" htmlFor="Peak">
Peak cut
</Label>
</div>
</div>
<div className="flex w-full flex-wrap">{chartButtons}</div>
</CardHeader>
<CardContent className="py-4 pr-2 pl-0 sm:pt-6 sm:pr-6 sm:pb-6 sm:pl-2">
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={true}
tickSize={3}
axisLine={false}
tickMargin={8}
minTickGap={80}
ticks={processedData
.filter((item, index, array) => {
if (array.length < 6) {
return index === 0 || index === array.length - 1
}
// 计算数据的总时间跨度(毫秒)
const timeSpan = array[array.length - 1].created_at - array[0].created_at
const hours = timeSpan / (1000 * 60 * 60)
// 根据时间跨度调整显示间隔
if (hours <= 12) {
// 12小时内每60分钟显示一个刻度
return (
index === 0 ||
index === array.length - 1 ||
new Date(item.created_at).getMinutes() % 60 === 0
)
}
// 超过12小时每2小时显示一个刻度
const date = new Date(item.created_at)
return date.getMinutes() === 0 && date.getHours() % 2 === 0
})
.map((item) => item.created_at)}
tickFormatter={(value) => {
const date = new Date(value)
const minutes = date.getMinutes()
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
}}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={15}
minTickGap={20}
tickFormatter={(value) => `${value}ms`}
/>
<ChartTooltip
isAnimationActive={false}
content={
<ChartTooltipContent
indicator={"line"}
labelKey="created_at"
labelFormatter={(_, payload) => {
return formatTime(payload[0].payload.created_at)
}}
/>
}
/>
{activeChart === defaultChart && <ChartLegend content={<ChartLegendContent />} />}
{chartLines}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
})
const transformData = (data: NezhaAPIMonitor[]) => {
const monitorData: ServerMonitorChart = {}
for (const item of data) {
const monitorName = item.monitor_name
if (!monitorData[monitorName]) {
monitorData[monitorName] = []
}
for (let i = 0; i < item.created_at.length; i++) {
monitorData[monitorName].push({
created_at: item.created_at[i],
avg_delay: item.avg_delay[i],
})
}
}
return monitorData
}
const formatData = (rawData: NezhaAPIMonitor[]) => {
const result: { [time: number]: ResultItem } = {}
const allTimes = new Set<number>()
for (const item of rawData) {
for (const time of item.created_at) {
allTimes.add(time)
}
}
const allTimeArray = Array.from(allTimes).sort((a, b) => a - b)
for (const item of rawData) {
const { monitor_name, created_at, avg_delay } = item
for (const time of allTimeArray) {
if (!result[time]) {
result[time] = { created_at: time }
}
const timeIndex = created_at.indexOf(time)
// @ts-expect-error - avg_delay is an array
result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null
}
}
return Object.values(result).sort((a, b) => a.created_at - b.created_at)
}

View File

@ -0,0 +1,892 @@
"use client"
import {
MAX_HISTORY_LENGTH,
type ServerDataWithTimestamp,
useServerData,
} from "@/app/context/server-data-context"
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import { ServerDetailChartLoading } from "@/components/loading/ServerDetailLoading"
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar"
import { Card, CardContent } from "@/components/ui/card"
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
import { formatBytes, formatNezhaInfo, formatRelativeTime } from "@/lib/utils"
import { useTranslations } from "next-intl"
import { useEffect, useRef, useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
type cpuChartData = {
timeStamp: string
cpu: number
}
type processChartData = {
timeStamp: string
process: number
}
type diskChartData = {
timeStamp: string
disk: number
}
type memChartData = {
timeStamp: string
mem: number
swap: number
}
type networkChartData = {
timeStamp: string
upload: number
download: number
}
type connectChartData = {
timeStamp: string
tcp: number
udp: number
}
export default function ServerDetailChartClient({
server_id,
}: {
server_id: number
show: boolean
}) {
const t = useTranslations("ServerDetailChartClient")
const { data: serverList, error, history } = useServerData()
const data = serverList?.result?.find((item) => item.id === server_id)
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
</div>
</>
)
}
if (!data) return <ServerDetailChartLoading />
return (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<CpuChart data={data} history={history} />
<ProcessChart data={data} history={history} />
<DiskChart data={data} history={history} />
<MemChart data={data} history={history} />
<NetworkChart data={data} history={history} />
<ConnectChart data={data} history={history} />
</section>
)
}
function CpuChart({
history,
data,
}: {
history: ServerDataWithTimestamp[]
data: NezhaAPISafe
}) {
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { cpu } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
cpu: cpu,
}
})
.filter((item): item is cpuChartData => item !== null)
.reverse() // 保持时间顺序
setCpuChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { cpu } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as cpuChartData[]
if (cpuChartData.length === 0) {
newData = [
{ timeStamp: timestamp, cpu: cpu },
{ timeStamp: timestamp, cpu: cpu },
]
} else {
newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setCpuChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
cpu: {
label: "CPU",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">CPU</p>
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{cpu.toFixed(0)}%</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={cpu}
primaryColor="hsl(var(--chart-1))"
/>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={cpuChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="cpu"
type="step"
fill="hsl(var(--chart-1))"
fillOpacity={0.3}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function ProcessChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [processChartData, setProcessChartData] = useState([] as processChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { process } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
process: process,
}
})
.filter((item): item is processChartData => item !== null)
.reverse()
setProcessChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { process } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as processChartData[]
if (processChartData.length === 0) {
newData = [
{ timeStamp: timestamp, process: process },
{ timeStamp: timestamp, process: process },
]
} else {
newData = [...processChartData, { timeStamp: timestamp, process: process }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setProcessChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
process: {
label: "Process",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">{t("Process")}</p>
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{process}</p>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={processChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis tickLine={false} axisLine={false} mirror={true} tickMargin={-15} />
<Area
isAnimationActive={false}
dataKey="process"
type="step"
fill="hsl(var(--chart-2))"
fillOpacity={0.3}
stroke="hsl(var(--chart-2))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function MemChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [memChartData, setMemChartData] = useState([] as memChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { mem, swap } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
mem: mem,
swap: swap,
}
})
.filter((item): item is memChartData => item !== null)
.reverse()
setMemChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { mem, swap } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as memChartData[]
if (memChartData.length === 0) {
newData = [
{ timeStamp: timestamp, mem: mem, swap: swap },
{ timeStamp: timestamp, mem: mem, swap: swap },
]
} else {
newData = [...memChartData, { timeStamp: timestamp, mem: mem, swap: swap }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setMemChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
mem: {
label: "Mem",
},
swap: {
label: "Swap",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<section className="flex items-center gap-4">
<div className="flex flex-col">
<p className=" text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center gap-2">
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={mem}
primaryColor="hsl(var(--chart-8))"
/>
<p className="font-medium text-xs">{mem.toFixed(0)}%</p>
</div>
</div>
<div className="flex flex-col">
<p className=" text-muted-foreground text-xs">{t("Swap")}</p>
<div className="flex items-center gap-2">
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={swap}
primaryColor="hsl(var(--chart-10))"
/>
<p className="font-medium text-xs">{swap.toFixed(0)}%</p>
</div>
</div>
</section>
<section className="flex flex-col items-end gap-0.5">
<div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.MemUsed)} / {formatBytes(data.host.MemTotal)}
</div>
<div className="flex items-center gap-2 font-medium text-[11px]">
swap: {formatBytes(data.status.SwapUsed)}
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={memChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="mem"
type="step"
fill="hsl(var(--chart-8))"
fillOpacity={0.3}
stroke="hsl(var(--chart-8))"
/>
<Area
isAnimationActive={false}
dataKey="swap"
type="step"
fill="hsl(var(--chart-10))"
fillOpacity={0.3}
stroke="hsl(var(--chart-10))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function DiskChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [diskChartData, setDiskChartData] = useState([] as diskChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { disk } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
disk: disk,
}
})
.filter((item): item is diskChartData => item !== null)
.reverse()
setDiskChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { disk } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as diskChartData[]
if (diskChartData.length === 0) {
newData = [
{ timeStamp: timestamp, disk: disk },
{ timeStamp: timestamp, disk: disk },
]
} else {
newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setDiskChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
disk: {
label: "Disk",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">{t("Disk")}</p>
<section className="flex flex-col items-end gap-0.5">
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{disk.toFixed(0)}%</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={disk}
primaryColor="hsl(var(--chart-5))"
/>
</section>
<div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.DiskUsed)} / {formatBytes(data.host.DiskTotal)}
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={diskChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="disk"
type="step"
fill="hsl(var(--chart-5))"
fillOpacity={0.3}
stroke="hsl(var(--chart-5))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function NetworkChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [networkChartData, setNetworkChartData] = useState([] as networkChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { up, down } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
upload: up,
download: down,
}
})
.filter((item): item is networkChartData => item !== null)
.reverse()
setNetworkChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { up, down } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as networkChartData[]
if (networkChartData.length === 0) {
newData = [
{ timeStamp: timestamp, upload: up, download: down },
{ timeStamp: timestamp, upload: up, download: down },
]
} else {
newData = [...networkChartData, { timeStamp: timestamp, upload: up, download: down }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setNetworkChartData(newData)
}
}, [data, historyLoaded])
let maxDownload = Math.max(...networkChartData.map((item) => item.download))
maxDownload = Math.ceil(maxDownload)
if (maxDownload < 1) {
maxDownload = 1
}
const chartConfig = {
upload: {
label: "Upload",
},
download: {
label: "Download",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
<section className="flex items-center gap-4">
<div className="flex w-20 flex-col">
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="font-medium text-xs">{up.toFixed(2)} M/s</p>
</div>
</div>
<div className="flex w-20 flex-col">
<p className=" text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="font-medium text-xs">{down.toFixed(2)} M/s</p>
</div>
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<LineChart
accessibilityLayer
data={networkChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
type="number"
minTickGap={50}
interval="preserveStartEnd"
domain={[1, maxDownload]}
tickFormatter={(value) => `${value.toFixed(0)}M/s`}
/>
<Line
isAnimationActive={false}
dataKey="upload"
type="linear"
stroke="hsl(var(--chart-1))"
strokeWidth={1}
dot={false}
/>
<Line
isAnimationActive={false}
dataKey="download"
type="linear"
stroke="hsl(var(--chart-4))"
strokeWidth={1}
dot={false}
/>
</LineChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function ConnectChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const [connectChartData, setConnectChartData] = useState([] as connectChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { tcp, udp } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
tcp: tcp,
udp: udp,
}
})
.filter((item): item is connectChartData => item !== null)
.reverse()
setConnectChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { tcp, udp } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as connectChartData[]
if (connectChartData.length === 0) {
newData = [
{ timeStamp: timestamp, tcp: tcp, udp: udp },
{ timeStamp: timestamp, tcp: tcp, udp: udp },
]
} else {
newData = [...connectChartData, { timeStamp: timestamp, tcp: tcp, udp: udp }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setConnectChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
tcp: {
label: "TCP",
},
udp: {
label: "UDP",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
<section className="flex items-center gap-4">
<div className="flex w-12 flex-col">
<p className="text-muted-foreground text-xs">TCP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="font-medium text-xs">{tcp}</p>
</div>
</div>
<div className="flex w-12 flex-col">
<p className=" text-muted-foreground text-xs">UDP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="font-medium text-xs">{udp}</p>
</div>
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<LineChart
accessibilityLayer
data={connectChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
type="number"
interval="preserveStartEnd"
/>
<Line
isAnimationActive={false}
dataKey="tcp"
type="linear"
stroke="hsl(var(--chart-1))"
strokeWidth={1}
dot={false}
/>
<Line
isAnimationActive={false}
dataKey="udp"
type="linear"
stroke="hsl(var(--chart-4))"
strokeWidth={1}
dot={false}
/>
</LineChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,276 @@
"use client"
import { useServerData } from "@/app/context/server-data-context"
import { BackIcon } from "@/components/Icon"
import ServerFlag from "@/components/ServerFlag"
import { ServerDetailLoading } from "@/components/loading/ServerDetailLoading"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import countries from "i18n-iso-countries"
import enLocale from "i18n-iso-countries/langs/en.json"
import { useTranslations } from "next-intl"
import { notFound, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
countries.registerLocale(enLocale)
export default function ServerDetailClient({
server_id,
}: {
server_id: number
}) {
const t = useTranslations("ServerDetailClient")
const router = useRouter()
const [hasHistory, setHasHistory] = useState(false)
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" })
}, [])
useEffect(() => {
const previousPath = sessionStorage.getItem("fromMainPage")
if (previousPath) {
setHasHistory(true)
}
}, [])
const linkClick = () => {
if (hasHistory) {
router.back()
} else {
router.push("/")
}
}
const { data: serverList, error, isLoading } = useServerData()
const serverData = serverList?.result?.find((item) => item.id === server_id)
if (!serverData && !isLoading) {
notFound()
}
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("detail_fetch_error_message")}</p>
</div>
</>
)
}
if (!serverData) return <ServerDetailLoading />
const {
name,
online,
uptime,
version,
arch,
mem_total,
disk_total,
country_code,
platform,
platform_version,
cpu_info,
gpu_info,
load_1,
load_5,
load_15,
net_out_transfer,
net_in_transfer,
last_active_time_string,
boot_time_string,
} = formatNezhaInfo(serverData)
return (
<div>
<div
onClick={linkClick}
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight transition-opacity duration-300 hover:opacity-50"
>
<BackIcon />
{name}
</div>
<section className="mt-3 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("status")}</p>
<Badge
className={cn(
"-mt-[0.3px] w-fit rounded-[6px] px-1 py-0 text-[9px] dark:text-white",
{
" bg-green-800": online,
" bg-red-600": !online,
},
)}
>
{online ? t("Online") : t("Offline")}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
<div className="text-xs">
{" "}
{uptime / 86400 >= 1
? `${Math.floor(uptime / 86400)} ${t("Days")} ${Math.floor((uptime % 86400) / 3600)} ${t("Hours")}`
: `${Math.floor(uptime / 3600)} ${t("Hours")}`}
</div>
</section>
</CardContent>
</Card>
{version && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Version")}</p>
<div className="text-xs">{version} </div>
</section>
</CardContent>
</Card>
)}
{arch && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Arch")}</p>
<div className="text-xs">{arch} </div>
</section>
</CardContent>
</Card>
)}
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="text-xs">{formatBytes(mem_total)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Disk")}</p>
<div className="text-xs">{formatBytes(disk_total)}</div>
</section>
</CardContent>
</Card>
{country_code && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Region")}</p>
<section className="flex items-start gap-1">
<div className="text-start text-xs">{countries.getName(country_code, "en")}</div>
<ServerFlag className="-mt-[1px] text-[11px]" country_code={country_code} />
</section>
</section>
</CardContent>
</Card>
)}
</section>
<section className="mt-1 flex flex-wrap gap-2">
{platform && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="text-xs">
{" "}
{platform} - {platform_version}{" "}
</div>
</section>
</CardContent>
</Card>
)}
{cpu_info && cpu_info.length > 0 && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="text-xs"> {cpu_info.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
{gpu_info && gpu_info.length > 0 && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"GPU"}</p>
<div className="text-xs"> {gpu_info.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
</section>
<section className="mt-1 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Load")}</p>
<div className="text-xs">
{load_1 || "0.00"} / {load_5 || "0.00"} / {load_15 || "0.00"}
</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
{net_out_transfer ? (
<div className="text-xs"> {formatBytes(net_out_transfer)} </div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Download")}</p>
{net_in_transfer ? (
<div className="text-xs"> {formatBytes(net_in_transfer)} </div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
</section>
<section className="mt-1 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("BootTime")}</p>
<div className="text-xs">{boot_time_string ? boot_time_string : "N/A"}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("LastActive")}</p>
<div className="text-xs">
{last_active_time_string ? last_active_time_string : "N/A"}
</div>
</section>
</CardContent>
</Card>
</section>
</div>
)
}

View File

@ -0,0 +1,119 @@
"use client"
import type { IPInfo } from "@/app/api/server-ip/route"
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent } from "@/components/ui/card"
import { nezhaFetcher } from "@/lib/utils"
import { useTranslations } from "next-intl"
import useSWRImmutable from "swr/immutable"
export default function ServerIPInfo({ server_id }: { server_id: number }) {
const t = useTranslations("IPInfo")
const { data } = useSWRImmutable<IPInfo>(`/api/server-ip?server_id=${server_id}`, nezhaFetcher)
if (!data) {
return (
<div className="mb-11">
<Loader visible />
</div>
)
}
return (
<>
<section className="mb-4 flex flex-wrap gap-2">
{data.asn?.autonomous_system_organization && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"ASN"}</p>
<div className="text-xs">{data.asn.autonomous_system_organization}</div>
</section>
</CardContent>
</Card>
)}
{data.asn?.autonomous_system_number && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("asn_number")}</p>
<div className="text-xs">AS{data.asn.autonomous_system_number}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.registered_country?.names.en && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("registered_country")}</p>
<div className="text-xs">{data.city.registered_country?.names.en}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.country?.iso_code && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"ISO"}</p>
<div className="text-xs">{data.city.country?.iso_code}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.city?.names.en && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("city")}</p>
<div className="text-xs">{data.city.city?.names.en}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.longitude && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("longitude")}</p>
<div className="text-xs">{data.city.location?.longitude}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.latitude && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("latitude")}</p>
<div className="text-xs">{data.city.location?.latitude}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.time_zone && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("time_zone")}</p>
<div className="text-xs">{data.city.location?.time_zone}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.postal && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("postal_code")}</p>
<div className="text-xs">{data.city.postal?.code}</div>
</section>
</CardContent>
</Card>
)}
</section>
</>
)
}

View File

@ -0,0 +1,62 @@
"use client"
import GlobalInfo from "@/app/(main)/ClientComponents/main/GlobalInfo"
import { InteractiveMap } from "@/app/(main)/ClientComponents/main/InteractiveMap"
import { useServerData } from "@/app/context/server-data-context"
import { TooltipProvider } from "@/app/context/tooltip-context"
import GlobalLoading from "@/components/loading/GlobalLoading"
import { geoJsonString } from "@/lib/geo/geo-json-string"
export default function ServerGlobal() {
const { data: nezhaServerList, error } = useServerData()
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
</div>
)
if (!nezhaServerList) {
return <GlobalLoading />
}
const countryList: string[] = []
const serverCounts: { [key: string]: number } = {}
for (const server of nezhaServerList.result) {
if (server.host.CountryCode) {
const countryCode = server.host.CountryCode.toUpperCase()
if (!countryList.includes(countryCode)) {
countryList.push(countryCode)
}
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1
}
}
const width = 900
const height = 500
const geoJson = JSON.parse(geoJsonString)
const filteredFeatures = geoJson.features.filter(
(feature: any) => feature.properties.iso_a3_eh !== "",
)
return (
<section className="mt-[3.2px] flex flex-col gap-4">
<GlobalInfo countries={countryList} />
<div className="w-full overflow-x-auto">
<TooltipProvider>
<InteractiveMap
countries={countryList}
serverCounts={serverCounts}
width={width}
height={height}
filteredFeatures={filteredFeatures}
nezhaServerList={nezhaServerList}
/>
</TooltipProvider>
</div>
</section>
)
}

View File

@ -0,0 +1,18 @@
"use client"
import { useTranslations } from "next-intl"
type GlobalInfoProps = {
countries: string[]
}
export default function GlobalInfo({ countries }: GlobalInfoProps) {
const t = useTranslations("Global")
return (
<section className="flex items-center justify-between">
<p className="font-medium text-sm opacity-40">
{t("Distributions")} {countries.length} {t("Regions")}
</p>
</section>
)
}

View File

@ -0,0 +1,153 @@
"use client"
import MapTooltip from "@/app/(main)/ClientComponents/main/MapTooltip"
import { useTooltip } from "@/app/context/tooltip-context"
import { countryCoordinates } from "@/lib/geo/geo-limit"
import { geoEquirectangular, geoPath } from "d3-geo"
interface InteractiveMapProps {
countries: string[]
serverCounts: { [key: string]: number }
width: number
height: number
filteredFeatures: any[]
nezhaServerList: any
}
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
nezhaServerList,
}: InteractiveMapProps) {
const { setTooltipData } = useTooltip()
const projection = geoEquirectangular()
.scale(140)
.translate([width / 2, height / 2])
.rotate([-12, 0, 0])
const path = geoPath().projection(projection)
return (
<div className="relative aspect-[2/1] w-full" onMouseLeave={() => setTooltipData(null)}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
className="h-auto w-full"
>
<title>Interactive Map</title>
<defs>
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
</pattern>
</defs>
<g>
{/* Background rect to handle mouse events in empty areas */}
<rect
x="0"
y="0"
width={width}
height={height}
fill="transparent"
onMouseEnter={() => setTooltipData(null)}
/>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(feature.properties.iso_a2_eh)
const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0
return (
<path
key={feature.properties.iso_a2_eh + String(index)}
d={path(feature) || ""}
className={
isHighlighted
? "cursor-pointer fill-green-700 transition-all hover:fill-green-600 dark:fill-green-900 dark:hover:fill-green-700"
: "fill-neutral-200/50 stroke-[0.5] stroke-neutral-300/40 dark:fill-neutral-800 dark:stroke-neutral-700"
}
onMouseEnter={() => {
if (!isHighlighted) {
setTooltipData(null)
return
}
if (path.centroid(feature)) {
const countryCode = feature.properties.iso_a2_eh
const countryServers = nezhaServerList.result
.filter(
(server: any) => server.host.CountryCode?.toUpperCase() === countryCode,
)
.map((server: any) => ({
id: server.id,
name: server.name,
status: server.online_status,
}))
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
servers: countryServers,
})
}
}}
/>
)
})}
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
{countries.map((countryCode) => {
// 检查该国家是否已经在 filteredFeatures 中
const isInFilteredFeatures = filteredFeatures.some(
(feature) => feature.properties.iso_a2_eh === countryCode,
)
// 如果已经在 filteredFeatures 中,跳过
if (isInFilteredFeatures) return null
// 获取国家的经纬度
const coords = countryCoordinates[countryCode]
if (!coords) return null
// 使用投影函数将经纬度转换为 SVG 坐标
const [x, y] = projection([coords.lng, coords.lat]) || [0, 0]
const serverCount = serverCounts[countryCode] || 0
return (
<g
key={countryCode}
onMouseEnter={() => {
const countryServers = nezhaServerList.result
.filter((server: any) => server.host.CountryCode?.toUpperCase() === countryCode)
.map((server: any) => ({
id: server.id,
name: server.name,
status: server.online_status,
}))
setTooltipData({
centroid: [x, y],
country: coords.name,
count: serverCount,
servers: countryServers,
})
}}
className="cursor-pointer"
>
<circle
cx={x}
cy={y}
r={4}
className="fill-sky-700 stroke-white transition-all hover:fill-sky-600 dark:fill-sky-900 dark:hover:fill-sky-700"
/>
</g>
)
})}
</g>
</svg>
<MapTooltip />
</div>
)
}

View File

@ -0,0 +1,70 @@
"use client"
import { useTooltip } from "@/app/context/tooltip-context"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { memo } from "react"
const MapTooltip = memo(function MapTooltip() {
const { tooltipData } = useTooltip()
const t = useTranslations("Global")
if (!tooltipData) return null
const sortedServers = tooltipData.servers.sort((a, b) => {
return a.status === b.status ? 0 : a.status ? 1 : -1
})
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return (
<div
className="tooltip-animate absolute z-50 hidden rounded bg-white px-2 py-1 text-sm shadow-lg lg:block dark:border dark:border-neutral-700 dark:bg-neutral-800"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(10%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation()
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China" ? "Mainland China" : tooltipData.country}
</p>
<p className="mb-1 font-light text-neutral-600 text-xs dark:text-neutral-400">
{tooltipData.count} {t("Servers")}
</p>
</div>
<div
className="border-t pt-1 dark:border-neutral-700"
style={{
maxHeight: "200px",
overflowY: "auto",
}}
>
{sortedServers.map((server) => (
<Link
onClick={saveSession}
href={`/server/${server.id}`}
key={server.name}
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
server.status ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-xs">{server.name}</span>
</Link>
))}
</div>
</div>
)
})
export default MapTooltip

View File

@ -0,0 +1,222 @@
"use client"
import { useFilter } from "@/app/context/network-filter-context"
import { useServerData } from "@/app/context/server-data-context"
import { useStatus } from "@/app/context/status-context"
import ServerCard from "@/components/ServerCard"
import ServerCardInline from "@/components/ServerCardInline"
import Switch from "@/components/Switch"
import GlobalLoading from "@/components/loading/GlobalLoading"
import { Loader } from "@/components/loading/Loader"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import { MapIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"
import { useTranslations } from "next-intl"
import dynamic from "next/dynamic"
import { useEffect, useRef, useState } from "react"
const ServerGlobal = dynamic(() => import("./Global"), {
ssr: false,
loading: () => <GlobalLoading />,
})
const sortServersByDisplayIndex = (servers: any[]) => {
return servers.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
return displayIndexDiff !== 0 ? displayIndexDiff : a.id - b.id
})
}
const filterServersByStatus = (servers: any[], status: string) => {
return status === "all"
? servers
: servers.filter((server) => [status].includes(server.online_status ? "online" : "offline"))
}
const filterServersByTag = (servers: any[], tag: string, defaultTag: string) => {
return tag === defaultTag ? servers : servers.filter((server) => server.tag === tag)
}
const sortServersByNetwork = (servers: any[]) => {
return [...servers].sort((a, b) => {
if (!a.online_status && b.online_status) return 1
if (a.online_status && !b.online_status) return -1
if (!a.online_status && !b.online_status) return 0
return b.status.NetInSpeed + b.status.NetOutSpeed - (a.status.NetInSpeed + a.status.NetOutSpeed)
})
}
const getTagCounts = (servers: any[]) => {
return servers.reduce((acc: Record<string, number>, server) => {
if (server.tag) {
acc[server.tag] = (acc[server.tag] || 0) + 1
}
return acc
}, {})
}
const LoadingState = ({ t }: { t: any }) => (
<div className="flex min-h-96 flex-col items-center justify-center ">
<div className="flex items-center gap-2 font-semibold text-sm">
<Loader visible={true} />
{t("connecting")}...
</div>
</div>
)
const ErrorState = ({ error, t }: { error: Error; t: any }) => (
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div>
)
const ServerList = ({
servers,
inline,
containerRef,
}: { servers: any[]; inline: string; containerRef: any }) => {
if (inline === "1") {
return (
<section
ref={containerRef}
className="scrollbar-hidden flex flex-col gap-2 overflow-x-scroll"
>
{servers.map((serverInfo) => (
<ServerCardInline key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)
}
return (
<section ref={containerRef} className="grid grid-cols-1 gap-2 md:grid-cols-2">
{servers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)
}
export default function ServerListClient() {
const { status } = useStatus()
const { filter } = useFilter()
const t = useTranslations("ServerListClient")
const containerRef = useRef<HTMLDivElement>(null)
const defaultTag = "defaultTag"
const [tag, setTag] = useState<string>(defaultTag)
const [showMap, setShowMap] = useState<boolean>(false)
const [inline, setInline] = useState<string>("0")
useEffect(() => {
const inlineState = localStorage.getItem("inline")
if (inlineState !== null) {
setInline(inlineState)
}
const showMapState = localStorage.getItem("showMap")
if (showMapState !== null) {
setShowMap(showMapState === "true")
}
const savedTag = sessionStorage.getItem("selectedTag") || defaultTag
setTag(savedTag)
restoreScrollPosition()
}, [])
const handleTagChange = (newTag: string) => {
setTag(newTag)
sessionStorage.setItem("selectedTag", newTag)
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
}
const restoreScrollPosition = () => {
const savedPosition = sessionStorage.getItem("scrollPosition")
if (savedPosition && containerRef.current) {
containerRef.current.scrollTop = Number(savedPosition)
}
}
useEffect(() => {
const handleRouteChange = () => {
restoreScrollPosition()
}
window.addEventListener("popstate", handleRouteChange)
return () => {
window.removeEventListener("popstate", handleRouteChange)
}
}, [])
const { data, error } = useServerData()
if (error) return <ErrorState error={error} t={t} />
if (!data?.result) return <LoadingState t={t} />
const { result } = data
const sortedServers = sortServersByDisplayIndex(result)
const filteredServersByStatus = filterServersByStatus(sortedServers, status)
const allTag = filteredServersByStatus.map((server) => server.tag).filter(Boolean)
const uniqueTags = [...new Set(allTag)]
uniqueTags.unshift(defaultTag)
let filteredServers = filterServersByTag(filteredServersByStatus, tag, defaultTag)
if (filter) {
filteredServers = sortServersByNetwork(filteredServers)
}
const tagCountMap = getTagCounts(filteredServersByStatus)
return (
<>
<section className="flex w-full items-center gap-2 overflow-hidden">
<button
type="button"
onClick={() => {
const newShowMap = !showMap
setShowMap(newShowMap)
localStorage.setItem("showMap", String(newShowMap))
}}
className={cn(
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
{
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
showMap,
},
)}
>
<MapIcon className="size-[13px]" />
</button>
<button
type="button"
onClick={() => {
const newInline = inline === "0" ? "1" : "0"
setInline(newInline)
localStorage.setItem("inline", newInline)
}}
className={cn(
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
{
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
inline === "1",
},
)}
>
<ViewColumnsIcon className="size-[13px]" />
</button>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
<Switch
allTag={uniqueTags}
nowTag={tag}
tagCountMap={tagCountMap}
onTagChange={handleTagChange}
/>
)}
</section>
{showMap && <ServerGlobal />}
<ServerList servers={filteredServers} inline={inline} containerRef={containerRef} />
</>
)
}

File diff suppressed because one or more lines are too long

58
app/(main)/footer.tsx Normal file
View File

@ -0,0 +1,58 @@
"use client"
import pack from "@/package.json"
import { useTranslations } from "next-intl"
import { useEffect, useState } from "react"
const GITHUB_URL = "https://github.com/hamster1963/nezha-dash"
const PERSONAL_URL = "https://buycoffee.top"
type LinkProps = {
href: string
children: React.ReactNode
}
const FooterLink = ({ href, children }: LinkProps) => (
<a
href={href}
target="_blank"
className="cursor-pointer font-normal underline decoration-2 decoration-yellow-500 underline-offset-2 transition-colors hover:decoration-yellow-600 dark:decoration-yellow-500/60 dark:hover:decoration-yellow-500/80"
rel="noreferrer"
>
{children}
</a>
)
const baseTextStyles =
"text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"
export default function Footer() {
const t = useTranslations("Footer")
const version = pack.version
const currentYear = new Date().getFullYear()
const [isMac, setIsMac] = useState(true)
useEffect(() => {
setIsMac(/macintosh|mac os x/i.test(navigator.userAgent))
}, [])
return (
<footer className="mx-auto flex w-full max-w-5xl items-center justify-between">
<section className="flex flex-col">
<p className={`mt-3 flex gap-1 ${baseTextStyles}`}>
{t("p_146-598_Findthecodeon")}{" "}
<FooterLink href={GITHUB_URL}>{t("a_303-585_GitHub")}</FooterLink>
<FooterLink href={`${GITHUB_URL}/releases/tag/v${version}`}>v{version}</FooterLink>
</p>
<section className={`mt-1 flex items-center gap-2 ${baseTextStyles}`}>
{t("section_607-869_2020")}
{currentYear} <FooterLink href={PERSONAL_URL}>{t("a_800-850_Hamster1963")}</FooterLink>
</section>
</section>
<p className={`mt-1 ${baseTextStyles}`}>
<kbd className="pointer-events-none mx-1 inline-flex h-4 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium font-mono text-[10px] text-muted-foreground opacity-100">
{isMac ? <span className="text-xs"></span> : "Ctrl "}K
</kbd>
</p>
</footer>
)
}

162
app/(main)/header.tsx Normal file
View File

@ -0,0 +1,162 @@
"use client"
import AnimateCountClient from "@/components/AnimatedCount"
import { LanguageSwitcher } from "@/components/LanguageSwitcher"
import { ModeToggle } from "@/components/ThemeSwitcher"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import getEnv from "@/lib/env-entry"
import { DateTime } from "luxon"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { memo, useCallback, useEffect, useState } from "react"
interface TimeState {
hh: number
mm: number
ss: number
}
interface CustomLink {
link: string
name: string
}
const useCurrentTime = () => {
const [time, setTime] = useState<TimeState>({
hh: DateTime.now().setLocale("en-US").hour,
mm: DateTime.now().setLocale("en-US").minute,
ss: DateTime.now().setLocale("en-US").second,
})
useEffect(() => {
const intervalId = setInterval(() => {
const now = DateTime.now().setLocale("en-US")
setTime({
hh: now.hour,
mm: now.minute,
ss: now.second,
})
}, 1000)
return () => clearInterval(intervalId)
}, [])
return time
}
const Links = memo(function Links() {
const linksEnv = getEnv("NEXT_PUBLIC_Links")
const links: CustomLink[] | null = linksEnv ? JSON.parse(linksEnv) : null
if (!links) return null
return (
<div className="flex items-center gap-2">
{links.map((link) => (
<a
key={link.link}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium text-sm opacity-50 transition-opacity hover:opacity-100"
>
{link.name}
</a>
))}
</div>
)
})
const Overview = memo(function Overview() {
const t = useTranslations("Overview")
const time = useCurrentTime()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return (
<section className={"mt-10 flex flex-col md:mt-16"}>
<p className="font-semibold text-base">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1">
<p className="font-medium text-sm opacity-50">{t("p_2390-2457_wherethetimeis")}</p>
{mounted ? (
<div className="flex items-center font-medium text-sm">
<AnimateCountClient count={time.hh} minDigits={2} />
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
<AnimateCountClient count={time.mm} minDigits={2} />
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
<span className="font-medium text-sm">
<AnimateCountClient count={time.ss} minDigits={2} />
</span>
</div>
) : (
<Skeleton className="h-[21px] w-16 animate-none rounded-[5px] bg-muted-foreground/10" />
)}
</div>
</section>
)
})
function Header() {
const t = useTranslations("Header")
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo")
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const router = useRouter()
const handleLogoClick = useCallback(() => {
sessionStorage.removeItem("selectedTag")
router.push("/")
}, [router])
return (
<div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between">
<section
onClick={handleLogoClick}
className="flex cursor-pointer items-center font-medium text-base transition-opacity duration-300 hover:opacity-50"
>
<div className="mr-1 flex flex-row items-center justify-start">
<img
width={40}
height={40}
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon.png"}
className="relative m-0! h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:hidden"
/>
<img
width={40}
height={40}
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon-dark.png"}
className="relative m-0! hidden h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:block"
/>
</div>
{customTitle ? customTitle : "NezhaDash"}
<Separator orientation="vertical" className="mx-2 hidden h-4 w-[1px] md:block" />
<p className="hidden font-medium text-sm opacity-40 md:block">
{customDescription ? customDescription : t("p_1079-1199_Simpleandbeautifuldashbo")}
</p>
</section>
<section className="flex items-center gap-2">
<div className="hidden sm:block">
<Links />
</div>
<LanguageSwitcher />
<ModeToggle />
</section>
</section>
<div className="mt-1 flex w-full justify-end sm:hidden">
<Links />
</div>
<Overview />
</div>
)
}
export default Header

38
app/(main)/layout.tsx Normal file
View File

@ -0,0 +1,38 @@
import Footer from "@/app/(main)/footer"
import Header from "@/app/(main)/header"
import { ServerDataProvider } from "@/app/context/server-data-context"
import { auth } from "@/auth"
import { DashCommand } from "@/components/DashCommand"
import { SignIn } from "@/components/SignIn"
import getEnv from "@/lib/env-entry"
import type React from "react"
type DashboardProps = {
children: React.ReactNode
}
export default function MainLayout({ children }: DashboardProps) {
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<Header />
<AuthProtected>
<ServerDataProvider>
{children}
<DashCommand />
</ServerDataProvider>
</AuthProtected>
<Footer />
</main>
</div>
)
}
async function AuthProtected({ children }: DashboardProps) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
return <SignIn />
}
}
return children
}

11
app/(main)/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import ServerListClient from "@/app/(main)/ClientComponents/main/ServerListClient"
import ServerOverviewClient from "@/app/(main)/ClientComponents/main/ServerOverviewClient"
export default async function Home() {
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverviewClient />
<ServerListClient />
</div>
)
}

View File

@ -0,0 +1,53 @@
"use client"
import { NetworkChartClient } from "@/app/(main)/ClientComponents/detail/NetworkChart"
import ServerDetailChartClient from "@/app/(main)/ClientComponents/detail/ServerDetailChartClient"
import ServerDetailClient from "@/app/(main)/ClientComponents/detail/ServerDetailClient"
import ServerIPInfo from "@/app/(main)/ClientComponents/detail/ServerIPInfo"
import TabSwitch from "@/components/TabSwitch"
import { Separator } from "@/components/ui/separator"
import getEnv from "@/lib/env-entry"
import { use, useState } from "react"
type PageProps = {
params: Promise<{ id: string }>
}
type TabType = "Detail" | "Network"
export default function Page({ params }: PageProps) {
const { id } = use(params)
const serverId = Number(id)
const tabs: TabType[] = ["Detail", "Network"]
const [currentTab, setCurrentTab] = useState<TabType>(tabs[0])
const tabContent = {
Detail: <ServerDetailChartClient server_id={serverId} show={currentTab === "Detail"} />,
Network: (
<>
{getEnv("NEXT_PUBLIC_ShowIpInfo") && <ServerIPInfo server_id={serverId} />}
<NetworkChartClient server_id={serverId} show={currentTab === "Network"} />
</>
),
}
return (
<main className="mx-auto grid w-full max-w-5xl gap-2">
<ServerDetailClient server_id={serverId} />
<nav className="my-2 flex w-full items-center">
<Separator className="flex-1" />
<div className="flex w-full max-w-[200px] justify-center">
<TabSwitch
tabs={tabs}
currentTab={currentTab}
setCurrentTab={(tab: string) => setCurrentTab(tab as TabType)}
/>
</div>
<Separator className="flex-1" />
</nav>
{tabContent[currentTab]}
</main>
)
}

View File

@ -1,285 +0,0 @@
"use client";
import NetworkChartLoading from "@/app/[locale]/(main)/ClientComponents/NetworkChartLoading";
import {
NezhaAPIMonitor,
ServerMonitorChart,
} from "@/app/[locale]/types/nezha-api";
import { BackIcon } from "@/components/Icon";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import getEnv from "@/lib/env-entry";
import { formatTime, nezhaFetcher } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import { useLocale } from "next-intl";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import * as React from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import useSWR from "swr";
import { useMemo, useCallback } from 'react';
interface ResultItem {
created_at: number;
[key: string]: number | null;
}
export function NetworkChartClient({ server_id }: { server_id: number }) {
const t = useTranslations("NetworkChartClient");
const { data, error } = useSWR<NezhaAPIMonitor[]>(
`/api/monitor?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval:
Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000,
},
);
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40">
{t("chart_fetch_error_message")}
</p>
</div>
<NetworkChartLoading />
</>
);
}
if (!data) return <NetworkChartLoading />;
const transformedData = transformData(data);
const formattedData = formatData(data);
const initChartConfig = {
avg_delay: {
label: t("avg_delay"),
},
} satisfies ChartConfig;
const chartDataKey = Object.keys(transformedData);
return (
<NetworkChart
chartDataKey={chartDataKey}
chartConfig={initChartConfig}
chartData={transformedData}
serverName={data[0].server_name}
formattedData={formattedData}
/>
);
}
export const NetworkChart = React.memo(function NetworkChart({
chartDataKey,
chartConfig,
chartData,
serverName,
formattedData,
}: {
chartDataKey: string[];
chartConfig: ChartConfig;
chartData: ServerMonitorChart;
serverName: string;
formattedData: ResultItem[];
}) {
const t = useTranslations("NetworkChart");
const router = useRouter();
const locale = useLocale();
const defaultChart = "All";
const [activeChart, setActiveChart] = React.useState(defaultChart);
const handleButtonClick = useCallback((chart: string) => {
setActiveChart(prev => prev === chart ? defaultChart : chart);
}, [defaultChart]);
const getColorByIndex = useCallback((chart: string) => {
const index = chartDataKey.indexOf(chart);
return `hsl(var(--chart-${(index % 10) + 1}))`;
}, [chartDataKey]);
const chartButtons = useMemo(() => (
chartDataKey.map((key) => (
<button
key={key}
data-active={activeChart === key}
className={`relative z-30 flex flex-1 flex-col justify-center gap-1 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{key}
</span>
<span className="text-md font-bold leading-none sm:text-lg">
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms
</span>
</button>
))
), [chartDataKey, activeChart, chartData, handleButtonClick]);
const chartLines = useMemo(() => {
if (activeChart !== defaultChart) {
return (
<Line
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey="avg_delay"
stroke={getColorByIndex(activeChart)}
/>
);
}
return chartDataKey.map((key) => (
<Line
key={key}
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey={key}
stroke={getColorByIndex(key)}
connectNulls={true}
/>
));
}, [activeChart, defaultChart, chartDataKey, getColorByIndex]);
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
<CardTitle
onClick={() => {
router.push(`/${locale}/`);
}}
className="flex flex-none cursor-pointer items-center gap-0.5 text-xl"
>
<BackIcon />
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("ServerMonitorCount")}
</CardDescription>
</div>
<div className="flex flex-wrap">
{chartButtons}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<LineChart
accessibilityLayer
data={activeChart === defaultChart ? formattedData : chartData[activeChart]}
margin={{ left: 12, right: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
interval={"preserveStartEnd"}
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
minTickGap={20}
tickFormatter={(value) => `${value}ms`}
/>
<ChartTooltip
isAnimationActive={false}
content={
<ChartTooltipContent
indicator={"line"}
labelKey="created_at"
labelFormatter={(_, payload) => {
return formatTime(payload[0].payload.created_at);
}}
/>
}
/>
{activeChart === defaultChart && (
<ChartLegend content={<ChartLegendContent />} />
)}
{chartLines}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
});
const transformData = (data: NezhaAPIMonitor[]) => {
const monitorData: ServerMonitorChart = {};
data.forEach((item) => {
const monitorName = item.monitor_name;
if (!monitorData[monitorName]) {
monitorData[monitorName] = [];
}
for (let i = 0; i < item.created_at.length; i++) {
monitorData[monitorName].push({
created_at: item.created_at[i],
avg_delay: item.avg_delay[i],
});
}
});
return monitorData;
}
const formatData = (rawData: NezhaAPIMonitor[]) => {
const result: { [time: number]: ResultItem } = {};
const allTimes = new Set<number>();
rawData.forEach((item) => {
item.created_at.forEach((time) => allTimes.add(time));
});
const allTimeArray = Array.from(allTimes).sort((a, b) => a - b);
rawData.forEach((item) => {
const { monitor_name, created_at, avg_delay } = item;
allTimeArray.forEach((time) => {
if (!result[time]) {
result[time] = { created_at: time };
}
const timeIndex = created_at.indexOf(time);
result[time][monitor_name] =
timeIndex !== -1 ? avg_delay[timeIndex] : null;
});
});
return Object.values(result).sort((a, b) => a.created_at - b.created_at);
};

View File

@ -1,35 +0,0 @@
import { BackIcon } from "@/components/Icon";
import { Loader } from "@/components/loading/Loader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
export default function NetworkChartLoading() {
const router = useRouter();
const locale = useLocale();
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5">
<CardTitle
onClick={() => {
router.push(`/${locale}/`);
}}
className="flex items-center cursor-pointer gap-0.5 text-xl"
>
<BackIcon />
<div className="aspect-auto h-[20px] w-24 bg-muted"></div>
</CardTitle>
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted"></div>
</div>
<div className="hidden pr-4 pt-4 sm:block">
<Loader visible={true} />
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<div className="aspect-auto h-[250px] w-full"></div>
</CardContent>
</Card>
);
}

View File

@ -1,59 +0,0 @@
"use client";
import { ServerApi } from "@/app/[locale]/types/nezha-api";
import ServerCard from "@/components/ServerCard";
import Switch from "@/components/Switch";
import getEnv from "@/lib/env-entry";
import { nezhaFetcher } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useState } from "react";
import useSWR from "swr";
export default function ServerListClient() {
const t = useTranslations("ServerListClient");
const [tag, setTag] = useState<string>(t("defaultTag"));
const { data, error } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
});
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
</div>
);
if (!data) return null;
const { result } = data;
const sortedServers = result.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0);
if (displayIndexDiff !== 0) return displayIndexDiff;
return a.id - b.id;
});
const allTag = sortedServers.map((server) => server.tag).filter((tag) => tag);
const uniqueTags = [...new Set(allTag)];
uniqueTags.unshift(t("defaultTag"));
const filteredServers =
tag === t("defaultTag")
? sortedServers
: sortedServers.filter((server) => server.tag === tag);
return (
<>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && uniqueTags.length > 1 && (
<Switch allTag={uniqueTags} nowTag={tag} setTag={setTag} />
)}
<section className="grid grid-cols-1 gap-2 md:grid-cols-2">
{filteredServers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
</>
);
}

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
import { NetworkChartClient } from "@/app/[locale]/(main)/ClientComponents/NetworkChart";
export default function Page({ params }: { params: { id: string } }) {
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<NetworkChartClient server_id={Number(params.id)} />
</div>
);
}

View File

@ -1,26 +0,0 @@
import { useTranslations } from "next-intl";
export default function Footer() {
const t = useTranslations("Footer");
return (
<footer className="mx-auto w-full max-w-5xl">
<section className="flex flex-col">
<p className="mt-3 flex gap-1 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
{t("p_146-598_Findthecodeon")}{" "}
<a
href="https://github.com/hamster1963/nezha-dash"
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 decoration-2 underline-offset-2 dark:decoration-yellow-500/50"
>
{t("a_303-585_GitHub")}
</a>
</p>
<section className="mt-1 flex items-center gap-2 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
{t("section_607-869_2020")}
{new Date().getFullYear()}{" "}
<a href={"https://buycoffee.top"}>{t("a_800-850_Hamster1963")}</a>
</section>
</section>
</footer>
);
}

View File

@ -1,95 +0,0 @@
"use client";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ModeToggle } from "@/components/ThemeSwitcher";
import { Separator } from "@/components/ui/separator";
import getEnv from "@/lib/env-entry";
import { DateTime } from "luxon";
import { useTranslations } from "next-intl";
import Image from "next/image";
import React, { useEffect, useRef, useState } from "react";
function Header() {
const t = useTranslations("Header");
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo");
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle");
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription");
return (
<div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between">
<section className="flex items-center text-base font-medium">
<div className="mr-1 flex flex-row items-center justify-start">
<Image
width={40}
height={40}
unoptimized
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon.png"}
className="relative !m-0 border-2 border-transparent h-6 w-6 object-cover object-top !p-0"
/>
</div>
{customTitle ? customTitle : "NezhaDash"}
<Separator
orientation="vertical"
className="mx-2 hidden h-4 w-[1px] md:block"
/>
<p className="hidden text-sm font-medium opacity-40 md:block">
{customDescription
? customDescription
: t("p_1079-1199_Simpleandbeautifuldashbo")}
</p>
</section>
<section className="flex items-center gap-2">
<LanguageSwitcher />
<ModeToggle />
</section>
</section>
<Overview />
</div>
);
}
// https://github.com/streamich/react-use/blob/master/src/useInterval.ts
const useInterval = (callback: Function, delay?: number | null) => {
const savedCallback = useRef<Function>(() => {});
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() => savedCallback.current(), delay || 0);
return () => clearInterval(interval);
}
return undefined;
}, [delay]);
};
function Overview() {
const t = useTranslations("Overview");
const [mouted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const timeOption = DateTime.TIME_SIMPLE;
timeOption.hour12 = true;
const [timeString, setTimeString] = useState(
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
);
useInterval(() => {
setTimeString(DateTime.now().setLocale("en-US").toLocaleString(timeOption));
}, 1000);
return (
<section className={"mt-10 flex flex-col md:mt-16"}>
<p className="text-base font-semibold">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium opacity-50">
{t("p_2390-2457_wherethetimeis")}
</p>
{mouted && (
<p className="opacity-1 text-sm font-medium">{timeString}</p>
)}
</div>
</section>
);
}
export default Header;

View File

@ -1,18 +0,0 @@
import Footer from "@/app/[locale]/(main)/footer";
import Header from "@/app/[locale]/(main)/header";
import React from "react";
type DashboardProps = {
children: React.ReactNode;
};
export default function MainLayout({ children }: DashboardProps) {
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 bg-muted/40 p-4 md:p-10 md:pt-8">
<Header />
{children}
<Footer />
</main>
</div>
);
}

View File

@ -1,17 +0,0 @@
import ServerList from "@/components/ServerList";
import ServerOverview from "@/components/ServerOverview";
import { unstable_setRequestLocale } from "next-intl/server";
export default function Home({
params: { locale },
}: {
params: { locale: string };
}) {
unstable_setRequestLocale(locale);
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverview />
<ServerList />
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 B

View File

@ -1,77 +0,0 @@
// @auto-i18n-check. Please do not delete the line.
import { locales } from "@/i18n-metadata";
import { cn } from "@/lib/utils";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Viewport } from "next";
import { NextIntlClientProvider, useMessages } from "next-intl";
import { unstable_setRequestLocale } from "next-intl/server";
import { PublicEnvScript } from "next-runtime-env";
import { ThemeProvider } from "next-themes";
import { Inter as FontSans } from "next/font/google";
import React from "react";
import "/node_modules/flag-icons/css/flag-icons.min.css";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
manifest: "/manifest.json",
title: "NezhaDash",
description: "A dashboard for nezha",
appleWebApp: {
capable: true,
title: "NezhaDash",
statusBarStyle: "black-translucent",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export async function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
unstable_setRequestLocale(locale);
const messages = useMessages();
return (
<html lang={locale} suppressHydrationWarning>
<head>
<PublicEnvScript />
</head>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -1,31 +0,0 @@
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
export default function NotFoundPage() {
const t = useTranslations("NotFoundPage");
return (
<main className="relative h-screen w-full">
<div className="absolute inset-0 m-4 flex items-center justify-center">
<Image
priority
className="rounded-3xl object-cover"
src="/tardis.jpg"
fill={true}
alt="TARDIS"
/>
<div className="text-container absolute right-4 p-4 md:right-20">
<h1 className="text-2xl font-bold opacity-80 md:text-5xl">
{t("h1_490-590_404NotFound")}
</h1>
<p className="text-lg opacity-60 md:text-base">
{t("p_601-665_TARDISERROR")}
</p>
<Link href={"/"} className="text-2xl opacity-80 md:text-3xl">
{t("Link_676-775_Doctor")}
</Link>
</div>
</div>
</main>
);
}

View File

@ -1,74 +0,0 @@
export type ServerApi = {
live_servers: number;
offline_servers: number;
total_bandwidth: number;
result: NezhaAPISafe[];
};
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">;
export interface NezhaAPI {
id: number;
name: string;
tag: string;
last_active: number;
online_status: boolean;
ipv4: string;
ipv6: string;
valid_ip: string;
display_index: number;
hide_for_guest: boolean;
host: NezhaAPIHost;
status: NezhaAPIStatus;
}
export interface NezhaAPIHost {
Platform: string;
PlatformVersion: string;
CPU: string[];
MemTotal: number;
DiskTotal: number;
SwapTotal: number;
Arch: string;
Virtualization: string;
BootTime: number;
CountryCode: string;
Version: string;
GPU: null;
}
export interface NezhaAPIStatus {
CPU: number;
MemUsed: number;
SwapUsed: number;
DiskUsed: number;
NetInTransfer: number;
NetOutTransfer: number;
NetInSpeed: number;
NetOutSpeed: number;
Uptime: number;
Load1: number;
Load5: number;
Load15: number;
TcpConnCount: number;
UdpConnCount: number;
ProcessCount: number;
Temperatures: number;
GPU: number;
}
export type ServerMonitorChart = {
[key: string]: {
created_at: number;
avg_delay: number;
}[];
};
export interface NezhaAPIMonitor {
monitor_id: number;
monitor_name: string;
server_id: number;
server_name: string;
created_at: number[];
avg_delay: number[];
}

View File

@ -1,2 +0,0 @@
export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
Partial<Pick<T, K>>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,3 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

44
app/api/detail/route.ts Normal file
View File

@ -0,0 +1,44 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerDetail } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const serverIdNum = Number.parseInt(server_id, 10)
if (Number.isNaN(serverIdNum)) {
return NextResponse.json({ error: "server_id must be a valid number" }, { status: 400 })
}
const detailData = await GetServerDetail({ server_id: serverIdNum })
return NextResponse.json(detailData, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,29 +1,46 @@
import { ServerMonitorChart } from "@/app/[locale]/types/nezha-api";
import { GetServerMonitor } from "@/lib/serverFetch";
import { NextResponse } from "next/server";
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerMonitor } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic";
export const dynamic = "force-dynamic"
interface NezhaDataResponse {
error?: string;
data?: ServerMonitorChart;
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const server_id = searchParams.get("server_id");
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json(
{ error: "server_id is required" },
{ status: 400 },
);
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
const response = (await GetServerMonitor({
server_id: parseInt(server_id),
})) as NezhaDataResponse;
if (response.error) {
console.log(response.error);
return NextResponse.json({ error: response.error }, { status: 400 });
try {
const serverIdNum = Number.parseInt(server_id, 10)
if (Number.isNaN(serverIdNum)) {
return NextResponse.json({ error: "server_id must be a number" }, { status: 400 })
}
const monitorData = await GetServerMonitor({
server_id: serverIdNum,
})
return NextResponse.json(monitorData, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
return NextResponse.json(response, { status: 200 });
}

View File

@ -0,0 +1,66 @@
import fs from "node:fs"
import path from "node:path"
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerIP } from "@/lib/serverFetch"
import { type AsnResponse, type CityResponse, Reader } from "maxmind"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export type IPInfo = {
city: CityResponse
asn: AsnResponse
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
if (!getEnv("NEXT_PUBLIC_ShowIpInfo")) {
return NextResponse.json({ error: "ip info is disabled" }, { status: 400 })
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const ip = await GetServerIP({ server_id: Number(server_id) })
const cityDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-City.mmdb")
const asnDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-ASN.mmdb")
const cityDbBuffer = fs.readFileSync(cityDbPath)
const asnDbBuffer = fs.readFileSync(asnDbPath)
const cityLookup = new Reader<CityResponse>(cityDbBuffer)
const asnLookup = new Reader<AsnResponse>(asnDbBuffer)
const data: IPInfo = {
city: cityLookup.get(ip) as CityResponse,
asn: asnLookup.get(ip) as AsnResponse,
}
return NextResponse.json(data, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,19 +1,32 @@
import { ServerApi } from "@/app/[locale]/types/nezha-api";
import { GetNezhaData } from "@/lib/serverFetch";
import { NextResponse } from "next/server";
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetNezhaData } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { NextResponse } from "next/server"
export const dynamic = "force-dynamic";
export const dynamic = "force-dynamic"
interface NezhaDataResponse {
error?: string;
data?: ServerApi;
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET(_: Request) {
const response = (await GetNezhaData()) as NezhaDataResponse;
if (response.error) {
console.log(response.error);
return NextResponse.json({ error: response.error }, { status: 400 });
export async function GET() {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
try {
const data = await GetNezhaData()
return NextResponse.json(data, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
return NextResponse.json(response, { status: 200 });
}

BIN
app/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,24 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
interface FilterContextType {
filter: boolean
setFilter: (filter: boolean) => void
}
const FilterContext = createContext<FilterContextType | undefined>(undefined)
export function FilterProvider({ children }: { children: ReactNode }) {
const [filter, setFilter] = useState<boolean>(false)
return <FilterContext.Provider value={{ filter, setFilter }}>{children}</FilterContext.Provider>
}
export function useFilter() {
const context = useContext(FilterContext)
if (context === undefined) {
throw new Error("useFilter must be used within a FilterProvider")
}
return context
}

View File

@ -0,0 +1,62 @@
"use client"
import type { ServerApi } from "@/app/types/nezha-api"
import getEnv from "@/lib/env-entry"
import { nezhaFetcher } from "@/lib/utils"
import { type ReactNode, createContext, useContext, useEffect, useState } from "react"
import useSWR from "swr"
export interface ServerDataWithTimestamp {
timestamp: number
data: ServerApi
}
interface ServerDataContextType {
data: ServerApi | undefined
error: Error | undefined
isLoading: boolean
history: ServerDataWithTimestamp[]
}
const ServerDataContext = createContext<ServerDataContextType | undefined>(undefined)
export const MAX_HISTORY_LENGTH = 30
export function ServerDataProvider({ children }: { children: ReactNode }) {
const [history, setHistory] = useState<ServerDataWithTimestamp[]>([])
const { data, error, isLoading } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
dedupingInterval: 1000,
})
useEffect(() => {
if (data) {
setHistory((prev) => {
const newHistory = [
{
timestamp: Date.now(),
data: data,
},
...prev,
].slice(0, MAX_HISTORY_LENGTH)
return newHistory
})
}
}, [data])
return (
<ServerDataContext.Provider value={{ data, error, isLoading, history }}>
{children}
</ServerDataContext.Provider>
)
}
export function useServerData() {
const context = useContext(ServerDataContext)
if (context === undefined) {
throw new Error("useServerData must be used within a ServerDataProvider")
}
return context
}

View File

@ -0,0 +1,26 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
type Status = "all" | "online" | "offline"
interface StatusContextType {
status: Status
setStatus: (status: Status) => void
}
const StatusContext = createContext<StatusContextType | undefined>(undefined)
export function StatusProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<Status>("all")
return <StatusContext.Provider value={{ status, setStatus }}>{children}</StatusContext.Provider>
}
export function useStatus() {
const context = useContext(StatusContext)
if (context === undefined) {
throw new Error("useStatus must be used within a StatusProvider")
}
return context
}

View File

@ -0,0 +1,39 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
export interface TooltipData {
centroid: [number, number]
country: string
count: number
servers: Array<{
id: string
name: string
status: boolean
}>
}
interface TooltipContextType {
tooltipData: TooltipData | null
setTooltipData: (data: TooltipData | null) => void
}
const TooltipContext = createContext<TooltipContextType | undefined>(undefined)
export function TooltipProvider({ children }: { children: ReactNode }) {
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null)
return (
<TooltipContext.Provider value={{ tooltipData, setTooltipData }}>
{children}
</TooltipContext.Provider>
)
}
export function useTooltip() {
const context = useContext(TooltipContext)
if (context === undefined) {
throw new Error("useTooltip must be used within a TooltipProvider")
}
return context
}

BIN
app/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

BIN
app/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

87
app/layout.tsx Normal file
View File

@ -0,0 +1,87 @@
import { FilterProvider } from "@/app/context/network-filter-context"
import { StatusProvider } from "@/app/context/status-context"
import { ThemeColorManager } from "@/components/ThemeColorManager"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import "@/styles/globals.css"
import type { Metadata } from "next"
import type { Viewport } from "next"
import { NextIntlClientProvider } from "next-intl"
import { getLocale, getMessages } from "next-intl/server"
import { PublicEnvScript } from "next-runtime-env"
import { ThemeProvider } from "next-themes"
import { Inter as FontSans } from "next/font/google"
import type React from "react"
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
})
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex")
export const metadata: Metadata = {
manifest: "/manifest.json",
title: customTitle || "NezhaDash",
description: customDescription || "A dashboard for nezha",
appleWebApp: {
capable: true,
title: customTitle || "NezhaDash",
statusBarStyle: "default",
},
robots: {
index: !disableIndex,
follow: !disableIndex,
},
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default async function LocaleLayout({
children,
}: {
children: React.ReactNode
}) {
const locale = await getLocale()
const messages = await getMessages()
return (
<html lang={locale} suppressHydrationWarning>
<head>
<PublicEnvScript />
<link
rel="stylesheet"
href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/>
<link
rel="stylesheet"
href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
/>
</head>
<body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable)}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider messages={messages}>
<FilterProvider>
<StatusProvider>
<ThemeColorManager />
{children}
</StatusProvider>
</FilterProvider>
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
)
}

22
app/not-found.tsx Normal file
View File

@ -0,0 +1,22 @@
import Footer from "@/app/(main)/footer"
import Header from "@/app/(main)/header"
import { useTranslations } from "next-intl"
import Link from "next/link"
export default function NotFoundPage() {
const t = useTranslations("NotFoundPage")
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<Header />
<section className="flex flex-1 flex-col items-center justify-center gap-2">
<p className="font-semibold text-sm">{t("h1_490-590_404NotFound")}</p>
<Link href="/" className="flex items-center gap-1">
<p className="font-medium text-sm opacity-40">{t("h1_490-590_404NotFoundBack")}</p>
</Link>
</section>
<Footer />
</main>
</div>
)
}

77
app/types/nezha-api.ts Normal file
View File

@ -0,0 +1,77 @@
export type ServerApi = {
live_servers: number
offline_servers: number
total_out_bandwidth: number
total_in_bandwidth: number
total_out_speed: number
total_in_speed: number
result: NezhaAPISafe[]
}
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">
export interface NezhaAPI {
id: number
name: string
tag: string
last_active: number
online_status: boolean
ipv4: string
ipv6: string
valid_ip: string
display_index: number
hide_for_guest: boolean
host: NezhaAPIHost
status: NezhaAPIStatus
}
export interface NezhaAPIHost {
Platform: string
PlatformVersion: string
CPU: string[]
MemTotal: number
DiskTotal: number
SwapTotal: number
Arch: string
Virtualization: string
BootTime: number
CountryCode: string
Version: string
GPU: string[]
}
export interface NezhaAPIStatus {
CPU: number
MemUsed: number
SwapUsed: number
DiskUsed: number
NetInTransfer: number
NetOutTransfer: number
NetInSpeed: number
NetOutSpeed: number
Uptime: number
Load1: number
Load5: number
Load15: number
TcpConnCount: number
UdpConnCount: number
ProcessCount: number
Temperatures: number
GPU: number
}
export type ServerMonitorChart = {
[key: string]: {
created_at: number
avg_delay: number
}[]
}
export interface NezhaAPIMonitor {
monitor_id: number
monitor_name: string
server_id: number
server_name: string
created_at: number[]
avg_delay: number[]
}

1
app/types/utils.ts Normal file
View File

@ -0,0 +1 @@
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

34
auth.ts Normal file
View File

@ -0,0 +1,34 @@
import getEnv from "@/lib/env-entry"
import CryptoJS from "crypto-js"
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
secret:
process.env.AUTH_SECRET ??
CryptoJS.MD5(`this_is_nezha_dash_web_secret_${getEnv("SitePassword")}`).toString(),
trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
providers: [
CredentialsProvider({
type: "credentials",
credentials: { password: { label: "Password", type: "password" } },
// authorization function
async authorize(credentials) {
const { password } = credentials
if (password === getEnv("SitePassword")) {
return { id: "nezha-dash-auth" }
}
return { error: "Invalid password" }
},
}),
],
callbacks: {
async signIn({ user }) {
// @ts-expect-error user is not null
if (user.error) {
return false
}
return true
},
},
})

View File

@ -1,10 +0,0 @@
{
"defaultLang": "en",
"translatorServerName": "azure",
"needLangs": ["en", "zh", "zh-t", "ja"],
"brandWords": [],
"unMoveToLocaleDirFiles": [],
"enableStaticRendering": false,
"enableSubPageRedirectToLocale": false,
"disableDefaultLangRedirect": true
}

86
biome.json Normal file
View File

@ -0,0 +1,86 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": { "ignoreUnknown": false, "ignore": [".next", "public", "styles/globals.css"] },
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"nursery": {
"useSortedClasses": "error"
},
"a11y": {
"useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": { "noUselessTypeConstraint": "error" },
"correctness": {
"noUnusedVariables": "error",
"useArrayLiterals": "off",
"useExhaustiveDependencies": "off"
},
"style": { "noNamespace": "error", "useAsConstAssertion": "error" },
"suspicious": {
"noExplicitAny": "off",
"noExtraNonNullAssertion": "error",
"noMisleadingInstantiator": "error",
"noUnsafeDeclarationMerging": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "error"
},
"style": {
"noArguments": "error",
"noVar": "error",
"useConst": "error"
},
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"useGetterReturn": "off"
}
}
}
}
]
}

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,15 @@
{
"types": {
"feat": { "title": "🚀 Features" },
"fix": { "title": "🔧 Bug Fixes" },
"docs": { "title": "📚 Documentation" },
"style": { "title": "💄 Styles" },
"refactor": { "title": "🔨 Refactor" },
"perf": { "title": "🏎 Performance" },
"test": { "title": "🚨 Tests" },
"build": { "title": "🛠 Build" },
"ci": { "title": "👷 CI" },
"chore": { "title": "🛗 Chore" },
"revert": { "title": "⏪ Revert" }
}
}

View File

@ -0,0 +1,96 @@
import { cn } from "@/lib/utils"
import { useEffect, useState } from "react"
export default function AnimateCountClient({
count,
className,
minDigits,
}: {
count: number
className?: string
minDigits?: number
}) {
const [previousCount, setPreviousCount] = useState(count)
useEffect(() => {
if (count !== previousCount) {
setTimeout(() => {
setPreviousCount(count)
}, 300)
}
}, [count])
return (
<AnimateCount
key={count}
preCount={previousCount}
className={cn("inline-flex items-center leading-none", className)}
minDigits={minDigits}
data-issues-count-animation
>
{count}
</AnimateCount>
)
}
export function AnimateCount({
children: count,
className,
preCount,
minDigits = 1,
...props
}: {
children: number
className?: string
preCount?: number
minDigits?: number
}) {
const currentDigits = count.toString().split("")
const previousDigits = (
preCount !== undefined ? preCount.toString() : count - 1 >= 0 ? (count - 1).toString() : "0"
).split("")
// Ensure both numbers meet the minimum length requirement and maintain the same length for animation
const maxLength = Math.max(previousDigits.length, currentDigits.length, minDigits)
while (previousDigits.length < maxLength) {
previousDigits.unshift("0")
}
while (currentDigits.length < maxLength) {
currentDigits.unshift("0")
}
return (
<div {...props} className={cn("flex h-[1em] items-center", className)}>
{currentDigits.map((digit, index) => {
const hasChanged = digit !== previousDigits[index]
return (
<div
key={`${index}-${digit}`}
className={cn("relative flex h-full min-w-[0.6em] items-center text-center", {
"min-w-[0.2em]": digit === ".",
})}
>
<div
aria-hidden
data-issues-count-exit
className={cn(
"absolute inset-0 flex items-center justify-center",
hasChanged ? "animate" : "opacity-0",
)}
>
{previousDigits[index]}
</div>
<div
data-issues-count-enter
className={cn(
"absolute inset-0 flex items-center justify-center",
hasChanged && "animate",
)}
>
{digit}
</div>
</div>
)
})}
</div>
)
}

View File

@ -1,35 +0,0 @@
import React from "react";
const BlurLayers = () => {
const computeLayerStyle = (index: number) => {
const blurAmount = index * 3.7037;
const maskStart = index * 10;
let maskEnd = maskStart + 20;
if (maskEnd > 100) {
maskEnd = 100;
}
return {
backdropFilter: `blur(${blurAmount}px)`,
WebkitBackdropFilter: `blur(${blurAmount}px)`,
zIndex: index + 1,
maskImage: `linear-gradient(rgba(0, 0, 0, 0) ${maskStart}%, rgb(0, 0, 0) ${maskEnd}%)`,
};
};
// 根据层数动态生成层
const layers = Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={"absolute inset-0 h-full w-full"}
style={computeLayerStyle(index)}
/>
));
return (
<div className={"fixed bottom-0 left-0 right-0 z-50 h-[140px]"}>
<div className={"relative h-full"}>{layers}</div>
</div>
);
};
export default BlurLayers;

135
components/DashCommand.tsx Normal file
View File

@ -0,0 +1,135 @@
"use client"
import { Home, Languages, Moon, Sun, SunMoon } from "lucide-react"
import { useServerData } from "@/app/context/server-data-context"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import { localeItems } from "@/i18n-metadata"
import { setUserLocale } from "@/i18n/locale"
import { useTranslations } from "next-intl"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
export function DashCommand() {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const { data } = useServerData()
const router = useRouter()
const { setTheme } = useTheme()
const t = useTranslations("DashCommand")
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
if (!data?.result) return null
const sortedServers = data.result.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
if (displayIndexDiff !== 0) return displayIndexDiff
return a.id - b.id
})
const languageShortcuts = localeItems.map((item) => ({
keywords: ["language", "locale", item.code.toLowerCase()],
icon: <Languages />,
label: item.name,
action: () => setUserLocale(item.code),
value: `language ${item.name.toLowerCase()} ${item.code}`,
}))
const shortcuts = [
{
keywords: ["home", "homepage"],
icon: <Home />,
label: t("Home"),
action: () => router.push("/"),
},
{
keywords: ["light", "theme", "lightmode"],
icon: <Sun />,
label: t("ToggleLightMode"),
action: () => setTheme("light"),
},
{
keywords: ["dark", "theme", "darkmode"],
icon: <Moon />,
label: t("ToggleDarkMode"),
action: () => setTheme("dark"),
},
{
keywords: ["system", "theme", "systemmode"],
icon: <SunMoon />,
label: t("ToggleSystemMode"),
action: () => setTheme("system"),
},
...languageShortcuts,
].map((item) => ({
...item,
value: `${item.keywords.join(" ")} ${item.label}`,
}))
return (
<>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t("TypeCommand")} value={search} onValueChange={setSearch} />
<CommandList className="border-t">
<CommandEmpty>{t("NoResults")}</CommandEmpty>
<CommandGroup heading={t("Servers")}>
{sortedServers.map((server) => (
<CommandItem
key={server.id}
value={server.name}
onSelect={() => {
router.push(`/server/${server.id}`)
setOpen(false)
}}
>
{server.online_status ? (
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
) : (
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
)}
<span>{server.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t("Shortcuts")}>
{shortcuts.map((item) => (
<CommandItem
key={item.label}
value={item.value}
onSelect={() => {
item.action()
setOpen(false)
}}
>
{item.icon}
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</>
)
}

File diff suppressed because one or more lines are too long

View File

@ -1,70 +1,58 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import * as React from "react";
import { localeItems } from "../i18n-metadata";
} from "@/components/ui/dropdown-menu"
import { localeItems } from "@/i18n-metadata"
import { setUserLocale } from "@/i18n/locale"
import { cn } from "@/lib/utils"
import { CheckCircleIcon, LanguageIcon } from "@heroicons/react/20/solid"
import { useLocale } from "next-intl"
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const locale = useLocale()
const handleChange = (code: string) => {
const newLocale = code;
const rootPath = "/";
const currentLocalePath = `/${locale}`;
const newLocalePath = `/${newLocale}`;
// Function to construct new path with locale prefix
const constructLocalePath = (path: string, newLocale: string) => {
if (path.startsWith(currentLocalePath)) {
return path.replace(currentLocalePath, `/${newLocale}`);
} else {
return `/${newLocale}${path}`;
}
};
if (pathname === rootPath || !pathname) {
router.push(newLocalePath);
} else if (
pathname === currentLocalePath ||
pathname === `${currentLocalePath}/`
) {
router.push(newLocalePath);
} else {
const newPath = constructLocalePath(pathname, newLocale);
router.push(newPath);
}
};
const handleSelect = (e: Event, newLocale: string) => {
e.preventDefault() // 阻止默认的关闭行为
setUserLocale(newLocale)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
{localeItems.find((item) => item.code === locale)?.name}
<Button
variant="outline"
size="sm"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<LanguageIcon className="size-4" />
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{localeItems.map((item) => (
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
{localeItems.map((item, index) => (
<DropdownMenuItem
key={item.code}
onClick={() => handleChange(item.code)}
onSelect={(e) => handleSelect(e, item.code)}
className={cn(
{
"gap-3 bg-muted font-semibold": locale === item.code,
},
{
"rounded-t-[5px]": index === localeItems.length - 1,
"rounded-[5px]": index !== 0 && index !== localeItems.length - 1,
"rounded-b-[5px]": index === 0,
},
)}
>
{item.name}
{item.name} {locale === item.code && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
)
}

View File

@ -1,172 +1,174 @@
import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api";
import ServerCardPopover from "@/components/ServerCardPopover";
import ServerFlag from "@/components/ServerFlag";
import ServerUsageBar from "@/components/ServerUsageBar";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import getEnv from "@/lib/env-entry";
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import { useTranslations } from "next-intl"
import Link from "next/link"
export default function ServerCard({
serverInfo,
}: {
serverInfo: NezhaAPISafe;
serverInfo: NezhaAPISafe
}) {
const t = useTranslations("ServerCard");
const router = useRouter();
const { id, name, country_code, online, cpu, up, down, mem, stg, ...props } =
formatNezhaInfo(serverInfo);
const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo)
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true";
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true";
const locale = useLocale();
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return online ? (
<Card
className={
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
}
>
<Popover>
<PopoverTrigger asChild>
<section
className="grid items-center gap-2 lg:w-28"
style={{ gridTemplateColumns: "auto 1fr auto" }}
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs" : "text-sm",
showFlag ? "text-xs " : "text-sm",
)}
>
{name}
</p>
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500 self-center"></span>
</section>
</PopoverTrigger>
<PopoverContent side="top">
<ServerCardPopover status={props.status} host={props.host} />
</PopoverContent>
</Popover>
<div
onClick={() => {
router.push(`/${locale}/${id}`);
}}
className="flex flex-col gap-2 cursor-pointer"
>
<section className={"grid grid-cols-5 items-center gap-3"}>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
<div className="flex items-center text-xs font-semibold">
{cpu.toFixed(2)}%
</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Mem")}</p>
<div className="flex items-center text-xs font-semibold">
{mem.toFixed(2)}%
</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("STG")}</p>
<div className="flex items-center text-xs font-semibold">
{stg.toFixed(2)}%
</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Upload")}</p>
<div className="flex items-center text-xs font-semibold">
{up.toFixed(2)}M/s
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Download")}</p>
<div className="flex items-center text-xs font-semibold">
{down.toFixed(2)}M/s
</div>
</div>
</section>
{showNetTransfer && (
<div className="flex flex-col gap-2">
<section
onClick={() => {
router.push(`/${locale}/${id}`);
}}
className={"flex items-center justify-between gap-1"}
className={cn("grid grid-cols-5 items-center gap-3", {
"lg:grid-cols-6 lg:gap-4": fixedTopServerName,
})}
>
<Badge
variant="secondary"
className="items-center flex-1 justify-center rounded-[8px] text-nowrap text-[11px] border-muted-50 shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
</Badge>
<Badge
variant="outline"
className="items-center flex-1 justify-center rounded-[8px] text-nowrap text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
</Badge>
</section>
)}
</div>
</Card>
) : (
<Card
className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row",
showNetTransfer
? "lg:min-h-[91px] min-h-[123px]"
: "lg:min-h-[61px] min-h-[93px]",
)}
>
<Popover>
<PopoverTrigger asChild>
<section
className="grid items-center gap-2 lg:w-28"
style={{ gridTemplateColumns: "auto 1fr auto" }}
>
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
{fixedTopServerName && (
<div className={"col-span-1 hidden items-center gap-2 lg:flex lg:flex-row"}>
<div className="font-semibold text-xs">
{host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" />
) : (
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
</div>
</div>
</div>
)}
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center font-semibold text-xs">
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center font-semibold text-xs">
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
</div>
</div>
</section>
{showNetTransfer && (
<section className={"flex items-center justify-between gap-1"}>
<Badge
variant="secondary"
className="flex-1 items-center justify-center text-nowrap rounded-[8px] border-muted-50 text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
</Badge>
<Badge
variant="outline"
className="flex-1 items-center justify-center text-nowrap rounded-[8px] text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
</Badge>
</section>
)}
</div>
</Card>
</Link>
) : (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
showNetTransfer ? "min-h-[123px] lg:min-h-[91px]" : "min-h-[93px] lg:min-h-[61px]",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs" : "text-sm",
)}
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
>
{name}
</p>
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500 self-center"></span>
</section>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" side="top">
<p className="text-sm text-muted-foreground">{t("Offline")}</p>
</PopoverContent>
</Popover>
</Card>
);
</div>
</section>
</Card>
</Link>
)
}

View File

@ -0,0 +1,157 @@
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"
import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { Separator } from "./ui/separator"
export default function ServerCardInline({
serverInfo,
}: {
serverInfo: NezhaAPISafe
}) {
const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo)
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return online ? (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex w-full min-w-[900px] cursor-pointer items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 lg:flex-row dark:hover:border-stone-700",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-36")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative w-28">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs " : "text-sm",
)}
>
{name}
</p>
</div>
</section>
<Separator orientation="vertical" className="mx-0 ml-2 h-8" />
<div className="flex flex-col gap-2">
<section className={cn("grid flex-1 grid-cols-9 items-center gap-3")}>
<div className={"flex flex-row items-center gap-2 whitespace-nowrap"}>
<div className="font-semibold text-xs">
{host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" />
) : (
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
</div>
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
<div className="flex items-center font-semibold text-xs">
{(serverInfo?.status.Uptime / 86400).toFixed(0)} {"Days"}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-16 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center font-semibold text-xs">
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-16 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center font-semibold text-xs">
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("TotalUpload")}</p>
<div className="flex items-center font-semibold text-xs">
{formatBytes(serverInfo.status.NetOutTransfer)}
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("TotalDownload")}</p>
<div className="flex items-center font-semibold text-xs">
{formatBytes(serverInfo.status.NetInTransfer)}
</div>
</div>
</section>
</div>
</Card>
</Link>
) : (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex min-h-[61px] min-w-[900px] flex-row items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-40")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative w-28">
<p
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
>
{name}
</p>
</div>
</section>
</Card>
</Link>
)
}

View File

@ -1,73 +0,0 @@
import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api";
import { cn, formatBytes } from "@/lib/utils";
import { useTranslations } from "next-intl";
export function ServerCardPopoverCard({
className,
title,
content,
children,
}: {
className?: string;
title: string;
content?: string;
children?: React.ReactNode;
}) {
return (
<div className={cn("mb-[6px] flex w-full flex-col", className)}>
<div className="text-sm font-semibold">{title}</div>
{children ? (
children
) : (
<div className="break-all text-xs font-medium">{content}</div>
)}
</div>
);
}
export default function ServerCardPopover({
host,
status,
}: {
host: NezhaAPISafe["host"];
status: NezhaAPISafe["status"];
}) {
const t = useTranslations("ServerCardPopover");
return (
<section className="max-w-[300px]">
<ServerCardPopoverCard
title={t("System")}
content={`${host.Platform}-${host.PlatformVersion} [${host.Virtualization}: ${host.Arch}]`}
/>
<ServerCardPopoverCard
title={t("CPU")}
content={`${host.CPU.map((item) => item).join(", ")}`}
/>
<ServerCardPopoverCard
title={t("Mem")}
content={`${formatBytes(status.MemUsed)} / ${formatBytes(host.MemTotal)}`}
/>
<ServerCardPopoverCard
title={t("STG")}
content={`${formatBytes(status.DiskUsed)} / ${formatBytes(host.DiskTotal)}`}
/>
<ServerCardPopoverCard
title={t("Swap")}
content={`${formatBytes(status.SwapUsed)} / ${formatBytes(host.SwapTotal)}`}
/>
<ServerCardPopoverCard
title={t("Network")}
content={`${formatBytes(status.NetOutTransfer)} / ${formatBytes(status.NetInTransfer)}`}
/>
<ServerCardPopoverCard
title={t("Load")}
content={`${status.Load1.toFixed(2)} / ${status.Load5.toFixed(2)} / ${status.Load15.toFixed(2)}`}
/>
<ServerCardPopoverCard
className="mb-0"
title={t("Online")}
content={`${(status.Uptime / 86400).toFixed(0)} Days`}
/>
</section>
);
}

View File

@ -1,49 +1,52 @@
import getEnv from "@/lib/env-entry";
import getUnicodeFlagIcon from "country-flag-icons/unicode";
import { useEffect, useState } from "react";
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import getUnicodeFlagIcon from "country-flag-icons/unicode"
import { useEffect, useState } from "react"
export default function ServerFlag({ country_code }: { country_code: string }) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false);
export default function ServerFlag({
country_code,
className,
}: {
country_code: string
className?: string
}) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(true)
const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true";
const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true"
useEffect(() => {
if (useSvgFlag) {
// 如果环境变量要求直接使用 SVG则无需检查 Emoji 支持
setSupportsEmojiFlags(false);
return;
setSupportsEmojiFlags(false)
return
}
const checkEmojiSupport = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const emojiFlag = "🇺🇸"; // 使用美国国旗作为测试
if (!ctx) return;
ctx.fillStyle = "#000";
ctx.textBaseline = "top";
ctx.font = "32px Arial";
ctx.fillText(emojiFlag, 0, 0);
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const emojiFlag = "🇺🇸" // 使用美国国旗作为测试
if (!ctx) return
ctx.fillStyle = "#000"
ctx.textBaseline = "top"
ctx.font = "32px Arial"
ctx.fillText(emojiFlag, 0, 0)
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0;
setSupportsEmojiFlags(support);
};
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0
setSupportsEmojiFlags(support)
}
checkEmojiSupport();
}, [useSvgFlag]); // 将 `useSvgFlag` 作为依赖,当其变化时重新触发
checkEmojiSupport()
}, [useSvgFlag]) // 将 `useSvgFlag` 作为依赖,当其变化时重新触发
if (!country_code) return null;
if (supportsEmojiFlags && country_code.toLowerCase() === "tw") {
country_code = "cn";
}
if (!country_code) return null
return (
<span className="text-[12px] text-muted-foreground">
<span className={cn("text-[12px] text-muted-foreground", className)}>
{useSvgFlag || !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`}></span>
<span className={`fi fi-${country_code}`} />
) : (
getUnicodeFlagIcon(country_code)
)}
</span>
);
)
}

View File

@ -1,6 +0,0 @@
import ServerListClient from "@/app/[locale]/(main)/ClientComponents/ServerListClient";
import React from "react";
export default async function ServerList() {
return <ServerListClient />;
}

View File

@ -1,5 +0,0 @@
import ServerOverviewClient from "@/app/[locale]/(main)/ClientComponents/ServerOverviewClient";
export default async function ServerOverview() {
return <ServerOverviewClient />;
}

View File

@ -1,9 +1,8 @@
import { Progress } from "@/components/ui/progress";
import React from "react";
import { Progress } from "@/components/ui/progress"
type ServerUsageBarProps = {
value: number;
};
value: number
}
export default function ServerUsageBar({ value }: ServerUsageBarProps) {
return (
@ -11,14 +10,8 @@ export default function ServerUsageBar({ value }: ServerUsageBarProps) {
aria-label={"Server Usage Bar"}
aria-labelledby={"Server Usage Bar"}
value={value}
indicatorClassName={
value > 90
? "bg-red-500"
: value > 70
? "bg-orange-400"
: "bg-green-500"
}
indicatorClassName={value > 90 ? "bg-red-500" : value > 70 ? "bg-orange-400" : "bg-green-500"}
className={"h-[3px] rounded-sm"}
/>
);
)
}

80
components/SignIn.tsx Normal file
View File

@ -0,0 +1,80 @@
"use client"
import { getCsrfToken, signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { Loader } from "./loading/Loader"
export function SignIn() {
const t = useTranslations("SignIn")
const [csrfToken, setCsrfToken] = useState("")
const [loading, setLoading] = useState(false)
const [errorState, setErrorState] = useState(false)
const [successState, setSuccessState] = useState(false)
const router = useRouter()
useEffect(() => {
async function loadProviders() {
const csrf = await getCsrfToken()
setCsrfToken(csrf)
}
loadProviders()
}, [])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
const password = formData.get("password") as string
const res = await signIn("credentials", {
password: password,
redirect: false,
})
if (res?.error) {
console.log("login error")
setErrorState(true)
setSuccessState(false)
} else {
console.log("login success")
setErrorState(false)
setSuccessState(true)
router.push("/")
router.refresh()
}
setLoading(false)
}
return (
<form
className="flex flex-1 flex-col items-center justify-center gap-4 p-4 "
onSubmit={handleSubmit}
>
<input type="hidden" name="csrfToken" value={csrfToken} />
<section className="flex flex-col items-start gap-2">
<label className="flex flex-col items-start gap-1 ">
{errorState && <p className="font-semibold text-red-500 text-sm">{t("ErrorMessage")}</p>}
{successState && (
<p className="font-semibold text-green-500 text-sm">{t("SuccessMessage")}</p>
)}
<p className="font-semibold text-base">{t("SignInMessage")}</p>
<input
className="rounded-[5px] border-[1px] border-stone-300 px-1 dark:border-stone-800"
name="password"
type="password"
/>
</label>
<button
type="submit"
className="flex w-fit items-center gap-1 rounded-[8px] border border-stone-300 bg-card px-1.5 py-0.5 font-semibold text-card-foreground text-sm shadow-lg shadow-neutral-200/40 transition-all hover:brightness-95 dark:border-stone-800 dark:shadow-none"
disabled={loading}
>
{t("Submit")}
{loading && <Loader visible={true} />}
</button>
</section>
</form>
)
}

View File

@ -1,48 +1,131 @@
"use client";
"use client"
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
import React from "react";
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import { useLocale, useTranslations } from "next-intl"
import { createRef, useEffect, useRef, useState } from "react"
export default function Switch({
allTag,
nowTag,
setTag,
tagCountMap,
onTagChange,
}: {
allTag: string[];
nowTag: string;
setTag: (tag: string) => void;
allTag: string[]
nowTag: string
tagCountMap: Record<string, number>
onTagChange: (tag: string) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>()))
const t = useTranslations("ServerListClient")
const locale = useLocale()
const [indicator, setIndicator] = useState<{ x: number; w: number } | null>(null)
const [isFirstRender, setIsFirstRender] = useState(true)
useEffect(() => {
const savedTag = sessionStorage.getItem("selectedTag")
if (savedTag && allTag.includes(savedTag)) {
onTagChange(savedTag)
}
}, [allTag, onTagChange])
useEffect(() => {
const container = scrollRef.current
if (!container) return
const isOverflowing = container.scrollWidth > container.clientWidth
if (!isOverflowing) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
container.scrollLeft += e.deltaY
}
container.addEventListener("wheel", onWheel, { passive: false })
return () => {
container.removeEventListener("wheel", onWheel)
}
}, [])
useEffect(() => {
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
if (currentTagElement) {
setIndicator({
x: currentTagElement.offsetLeft,
w: currentTagElement.offsetWidth,
})
}
if (isFirstRender) {
setTimeout(() => {
setIsFirstRender(false)
}, 50)
}
}, [nowTag, locale, allTag, isFirstRender])
useEffect(() => {
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
const container = scrollRef.current
if (currentTagElement && container) {
const containerRect = container.getBoundingClientRect()
const tagRect = currentTagElement.getBoundingClientRect()
const scrollLeft = currentTagElement.offsetLeft - (containerRect.width - tagRect.width) / 2
container.scrollTo({
left: Math.max(0, scrollLeft),
behavior: "smooth",
})
}
}, [nowTag, locale])
return (
<div className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
<div className="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{allTag.map((tag) => (
<div
ref={scrollRef}
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
>
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{indicator && (
<div
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 24,
width: `${indicator.w}px`,
transform: `translateX(${indicator.x}px)`,
transition: isFirstRender ? "none" : "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{allTag.map((tag, index) => (
<div
key={tag}
onClick={() => setTag(tag)}
ref={tagRefs.current[index]}
onClick={() => {
onTagChange(tag)
sessionStorage.setItem("selectedTag", tag)
}}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500",
nowTag === tag
? "text-black dark:text-white"
: "text-stone-400 dark:text-stone-500",
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
{
"text-stone-950 dark:text-stone-50": nowTag === tag,
},
)}
>
{nowTag === tag && (
<motion.div
layoutId="nav-item"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{tag}</p>
<div className="flex items-center gap-2 whitespace-nowrap">
{tag === "defaultTag" ? t("defaultTag") : tag}{" "}
{getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && tag !== "defaultTag" && (
<div className="w-fit rounded-full bg-muted px-1.5">{tagCountMap[tag]}</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
)
}

75
components/TabSwitch.tsx Normal file
View File

@ -0,0 +1,75 @@
"use client"
import { cn } from "@/lib/utils"
import { useLocale, useTranslations } from "next-intl"
import { useEffect, useRef, useState } from "react"
export default function TabSwitch({
tabs,
currentTab,
setCurrentTab,
}: {
tabs: string[]
currentTab: string
setCurrentTab: (tab: string) => void
}) {
const t = useTranslations("TabSwitch")
const [indicator, setIndicator] = useState<{ x: number; w: number }>({
x: 0,
w: 0,
})
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const locale = useLocale()
useEffect(() => {
const currentTabElement = tabRefs.current[tabs.indexOf(currentTab)]
if (currentTabElement) {
const parentPadding = 1
setIndicator({
x:
tabs.indexOf(currentTab) !== 0
? currentTabElement.offsetLeft - parentPadding
: currentTabElement.offsetLeft,
w: currentTabElement.offsetWidth,
})
}
}, [currentTab, tabs, locale])
return (
<div className="z-50 flex flex-col items-start rounded-[50px]">
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{indicator.w > 0 && (
<div
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 24,
width: `${indicator.w}px`,
transform: `translateX(${indicator.x}px)`,
transition: "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{tabs.map((tab: string, index) => (
<div
key={tab}
ref={(el) => {
tabRefs.current[index] = el
}}
onClick={() => setCurrentTab(tab)}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
{
"text-stone-950 dark:text-stone-50": currentTab === tab,
},
)}
>
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(tab)}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
"use client"
import { useTheme } from "next-themes"
import { useEffect } from "react"
export function ThemeColorManager() {
const { theme, systemTheme } = useTheme()
useEffect(() => {
const updateThemeColor = () => {
const currentTheme = theme === "system" ? systemTheme : theme
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
const newMeta = document.createElement("meta")
newMeta.name = "theme-color"
document.head.appendChild(newMeta)
}
const themeColor =
currentTheme === "dark"
? "hsl(30 15% 8%)" // 深色模式背景色
: "hsl(0 0% 98%)" // 浅色模式背景色
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}
// Update on mount and theme change
updateThemeColor()
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
mediaQuery.addEventListener("change", updateThemeColor)
return () => mediaQuery.removeEventListener("change", updateThemeColor)
}, [theme, systemTheme])
return null
}

View File

@ -1,40 +1,80 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Moon, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import * as React from "react";
} from "@/components/ui/dropdown-menu"
import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react"
import { useTranslations } from "next-intl"
import { useTheme } from "next-themes"
import { useId } from "react"
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
const items = [
{ value: "light", label: "Light", image: "/ui-light.png" },
{ value: "dark", label: "Dark", image: "/ui-dark.png" },
{ value: "system", label: "System", image: "/ui-system.png" },
]
export function ModeToggle() {
const { setTheme } = useTheme();
const t = useTranslations("ThemeSwitcher");
const { setTheme, theme } = useTheme()
const t = useTranslations("ThemeSwitcher")
const handleSelect = (newTheme: string) => {
setTheme(newTheme)
}
const id = useId()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Button
variant="outline"
size="sm"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<Sun className="dark:-rotate-90 h-4 w-4 rotate-0 scale-100 transition-all dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
{t("Light")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{t("Dark")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{t("System")}
</DropdownMenuItem>
<DropdownMenuContent className="px-2 pt-2 pb-1.5" align="end">
<fieldset className="space-y-4">
<RadioGroup className="flex gap-2" defaultValue={theme} onValueChange={handleSelect}>
{items.map((item) => (
<label key={`${id}-${item.value}`}>
<RadioGroupItem
id={`${id}-${item.value}`}
value={item.value}
className="peer sr-only after:absolute after:inset-0"
/>
<img
src={item.image}
alt={item.label}
width={88}
height={70}
className="relative cursor-pointer overflow-hidden rounded-[8px] border border-neutral-300 shadow-xs outline-none transition-[color,box-shadow] peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-data-disabled:cursor-not-allowed peer-data-[state=checked]:bg-accent peer-data-disabled:opacity-50 dark:border-neutral-700"
/>
<span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
<CheckIcon
size={16}
className="group-peer-data-[state=unchecked]:hidden"
aria-hidden="true"
/>
<MinusIcon
size={16}
className="group-peer-data-[state=checked]:hidden"
aria-hidden="true"
/>
<span className="font-medium text-xs">{t(item.label)}</span>
</span>
</label>
))}
</RadioGroup>
</fieldset>
</DropdownMenuContent>
</DropdownMenu>
);
)
}

View File

@ -0,0 +1,16 @@
"use client"
import { Loader } from "@/components/loading/Loader"
import { useTranslations } from "next-intl"
export default function GlobalLoading() {
const t = useTranslations("Global")
return (
<section className="mt-[3.2px] flex flex-col gap-4">
<div className="flex min-h-40 flex-col items-center justify-center font-medium text-sm">
{t("Loading")}
<Loader visible={true} />
</div>
</section>
)
}

View File

@ -1,13 +1,13 @@
const bars = Array(8).fill(0);
const bars = Array(8).fill(0)
export const Loader = ({ visible }: { visible: boolean }) => {
return (
<div className="hamster-loading-wrapper" data-visible={visible}>
<div className="hamster-spinner">
{bars.map((_, i) => (
<div className="hamster-loading-bar" key={`hamster-bar-${i}`} />
<div className="hamster-loading-bar" key={`hamster-bar-${i + 1}`} />
))}
</div>
</div>
);
};
)
}

View File

@ -0,0 +1,23 @@
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function NetworkChartLoading() {
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5">
<CardTitle className="flex items-center gap-0.5 text-xl">
<div className="aspect-auto h-[20px] w-24 bg-muted" />
</CardTitle>
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted" />
</div>
<div className="hidden pt-4 pr-4 sm:block">
<Loader visible={true} />
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<div className="aspect-auto h-[250px] w-full" />
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,37 @@
import { BackIcon } from "@/components/Icon"
import { Skeleton } from "@/components/ui/skeleton"
import { useRouter } from "next/navigation"
export function ServerDetailChartLoading() {
return (
<div>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
</section>
</div>
)
}
export function ServerDetailLoading() {
const router = useRouter()
return (
<>
<div
onClick={() => {
router.push("/")
}}
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight"
>
<BackIcon />
<Skeleton className="h-[20px] w-24 animate-none rounded-[5px] bg-muted-foreground/10" />
</div>
<Skeleton className="mt-3 flex h-[81px] w-1/2 animate-none flex-wrap gap-2 rounded-[5px] bg-muted-foreground/10" />
</>
)
}

View File

@ -0,0 +1,101 @@
import { cn } from "@/lib/utils"
interface Props {
max: number
value: number
min: number
className?: string
primaryColor?: string
}
export default function AnimatedCircularProgressBar({
max = 100,
min = 0,
value = 0,
primaryColor,
className,
}: Props) {
const circumference = 2 * Math.PI * 45
const percentPx = circumference / 100
const currentPercent = ((value - min) / (max - min)) * 100
return (
<div
className={cn("relative size-40 font-semibold text-2xl", className)}
style={
{
"--circle-size": "100px",
"--circumference": circumference,
"--percent-to-px": `${percentPx}px`,
"--gap-percent": "5",
"--offset-factor": "0",
"--transition-length": "1s",
"--transition-step": "200ms",
"--delay": "0s",
"--percent-to-deg": "3.6deg",
transform: "translateZ(0)",
} as React.CSSProperties
}
>
<svg fill="none" className="size-full" strokeWidth="2" viewBox="0 0 100 100">
<title>Circular Progress Bar</title>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-muted opacity-100"
style={
{
"--stroke-percent": 90 - currentPercent,
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
transition: "all var(--transition-length) ease var(--delay)",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
)}
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("stroke-current opacity-100", {
"stroke-[var(--stroke-primary-color)]": primaryColor,
})}
style={
{
"--stroke-primary-color": primaryColor,
"--stroke-percent": currentPercent,
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transition:
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
transitionProperty: "stroke-dasharray,transform",
transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="fade-in absolute inset-0 m-auto size-fit animate-in delay-[var(--delay)] duration-[var(--transition-length)] ease-linear"
>
{currentPercent}
</span>
</div>
)
}

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