Compare commits

...

299 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
140 changed files with 5926 additions and 4020 deletions

View File

@ -15,3 +15,4 @@ 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

View File

@ -1,8 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@next/next/no-img-element": "off",
"react-hooks/exhaustive-deps": "off"
}
}

BIN
.github/1-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

BIN
.github/1.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

BIN
.github/2-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

BIN
.github/2.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

BIN
.github/3-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

BIN
.github/3.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

BIN
.github/4-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

BIN
.github/4.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

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/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,6 +19,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -56,20 +60,12 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
release:
runs-on: ubuntu-latest
needs: build-and-push
steps:
- uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
fetch-depth: 0
bun-version: "latest"
- name: Set node
uses: actions/setup-node@v4
with:
registry-url: https://registry.npmjs.org/
node-version: lts/*
- run: npx changelogithub
- name: Changelog
run: bun x changelogithub
env:
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.'
});

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

@ -4,18 +4,23 @@
</div>
> [!CAUTION]
> 此为 V0 兼容版本,与 V1 内置版本功能上可能有所不同
>
> V0 | V1 版本 issue 请在当前仓库发起
> [!TIP]
> 有关 V1 版本 pr 可移步 https://github.com/hamster1963/nezha-dash-v1
### 部署
支持部署环境:
> [!CAUTION]
> Cloudflare 部署方式暂不可用,等待 Cloudflare 官方修复。
- Vercel
- Cloudflare
- Docker
[演示站点](https://nezha-cf.buycoffee.top)
[演示站点](https://nezha-vercel.vercel.app)
[说明文档](https://nezhadash-docs.vercel.app)
### 如何更新
@ -26,11 +31,8 @@
[环境变量介绍](https://nezhadash-docs.vercel.app/environment)
![screen](/.github/1.webp)
![screen](/.github/2.webp)
![screen](/.github/3.webp)
![screen](/.github/4.webp)
![screen](/.github/1-dark.webp)
![screen](/.github/2-dark.webp)
![screen](/.github/3-dark.webp)
![screen](/.github/4-dark.webp)
![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

@ -1,45 +0,0 @@
import { GetNezhaData } from "@/lib/serverFetch";
import { geoJsonString } from "../../../lib/geo-json-string";
import GlobalInfo from "./GlobalInfo";
import { InteractiveMap } from "./InteractiveMap";
export default async function ServerGlobal() {
const nezhaServerList = await GetNezhaData();
const countryList: string[] = [];
const serverCounts: { [key: string]: number } = {};
nezhaServerList.result.forEach((server) => {
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="flex flex-col gap-4 mt-[3.2px]">
<GlobalInfo countries={countryList} />
<div className="w-full overflow-x-auto">
<InteractiveMap
countries={countryList}
serverCounts={serverCounts}
width={width}
height={height}
filteredFeatures={filteredFeatures}
/>
</div>
</section>
);
}

View File

@ -1,20 +0,0 @@
"use client";
import GlobalBackButton from "@/components/GlobalBackButton";
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">
<GlobalBackButton />
<p className="text-sm font-medium opacity-40">
{t("Distributions")} {countries.length} {t("Regions")}
</p>
</section>
);
}

View File

@ -1,18 +0,0 @@
"use client";
import GlobalBackButton from "@/components/GlobalBackButton";
import { Loader } from "@/components/loading/Loader";
import { useTranslations } from "next-intl";
export default function GlobalLoading() {
const t = useTranslations("Global");
return (
<section className="flex flex-col gap-4 mt-[3.2px]">
<GlobalBackButton />
<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,110 +0,0 @@
"use client";
import { geoEquirectangular, geoPath } from "d3-geo";
import { AnimatePresence, m } from "framer-motion";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface InteractiveMapProps {
countries: string[];
serverCounts: { [key: string]: number };
width: number;
height: number;
filteredFeatures: any[];
}
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
}: InteractiveMapProps) {
const t = useTranslations("Global");
const [tooltipData, setTooltipData] = useState<{
centroid: [number, number];
country: string;
count: number;
} | null>(null);
const projection = geoEquirectangular()
.scale(140)
.translate([width / 2, height / 2])
.rotate([-12, 0, 0]);
const path = geoPath().projection(projection);
return (
<div className="relative w-full aspect-[2/1]">
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
className="w-full h-auto"
>
<defs>
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
</pattern>
</defs>
<g>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(
feature.properties.iso_a2_eh,
);
if (isHighlighted) {
console.log(feature.properties.iso_a2_eh);
}
const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0;
return (
<path
key={index}
d={path(feature) || ""}
className={
isHighlighted
? "fill-orange-500 hover:fill-orange-300 stroke-orange-500 dark:stroke-amber-900 dark:fill-amber-900 dark:hover:fill-amber-700 transition-all cursor-pointer"
: "fill-neutral-200/50 dark:fill-neutral-800 stroke-neutral-300/40 dark:stroke-neutral-700 stroke-[0.5]"
}
onMouseEnter={() => {
if (isHighlighted && path.centroid(feature)) {
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
});
}
}}
onMouseLeave={() => setTooltipData(null)}
/>
);
})}
</g>
</svg>
<AnimatePresence mode="wait">
{tooltipData && (
<m.div
initial={{ opacity: 0, filter: "blur(10px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
className="absolute hidden lg:block pointer-events-none bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(-50%, -50%)",
}}
>
<p className="font-medium">{tooltipData.country}</p>
<p className="text-neutral-600 dark:text-neutral-400">
{tooltipData.count} {t("Servers")}
</p>
</m.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,285 +0,0 @@
"use client";
import NetworkChartLoading from "@/app/(main)/ClientComponents/NetworkChartLoading";
import { NezhaAPIMonitor, ServerMonitorChart } from "@/app/types/nezha-api";
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 { 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 | null;
}
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="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 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 cursor-pointer flex-1 flex-col justify-center gap-1 border-b border-neutral-200 dark:border-neutral-800 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 className="flex flex-none items-center gap-0.5 text-md">
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("ServerMonitorCount")}
</CardDescription>
</div>
<div className="flex flex-wrap">{chartButtons}</div>
</CardHeader>
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2">
<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}
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,766 +0,0 @@
"use client";
import { ServerDetailChartLoading } from "@/app/(main)/ClientComponents/ServerDetailLoading";
import { NezhaAPISafe, ServerApi } from "@/app/types/nezha-api";
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar";
import { Card, CardContent } from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
import getEnv from "@/lib/env-entry";
import { formatNezhaInfo, formatRelativeTime, nezhaFetcher } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import {
Area,
AreaChart,
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
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,
show,
}: {
server_id: number;
show: boolean;
}) {
const t = useTranslations("ServerDetailChartClient");
const { data: allFallbackData } = useSWRImmutable<ServerApi>(
"/api/server",
nezhaFetcher,
);
const fallbackData = allFallbackData?.result?.find(
(item) => item.id === server_id,
);
const { data, error } = useSWR<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
isVisible: () => show,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);
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>
</>
);
}
if (!data) return <ServerDetailChartLoading />;
return (
<section className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-3">
<CpuChart data={data} />
<ProcessChart data={data} />
<DiskChart data={data} />
<MemChart data={data} />
<NetworkChart data={data} />
<ConnectChart data={data} />
</section>
);
}
function CpuChart({ data }: { data: NezhaAPISafe }) {
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[]);
const { cpu } = formatNezhaInfo(data);
useEffect(() => {
if (data) {
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 > 30) {
newData.shift();
}
setCpuChartData(newData);
}
}, [data]);
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="text-md font-medium">CPU</p>
<section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium">
{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 }: { data: NezhaAPISafe }) {
const t = useTranslations("ServerDetailChartClient");
const [processChartData, setProcessChartData] = useState(
[] as processChartData[],
);
const { process } = formatNezhaInfo(data);
useEffect(() => {
if (data) {
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 > 30) {
newData.shift();
}
setProcessChartData(newData);
}
}, [data]);
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="text-md font-medium">{t("Process")}</p>
<section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium">{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 }: { data: NezhaAPISafe }) {
const t = useTranslations("ServerDetailChartClient");
const [memChartData, setMemChartData] = useState([] as memChartData[]);
const { mem, swap } = formatNezhaInfo(data);
useEffect(() => {
if (data) {
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 > 30) {
newData.shift();
}
setMemChartData(newData);
}
}, [data]);
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">
<section className="flex items-center gap-4">
<div className="flex flex-col">
<p className=" text-xs text-muted-foreground">{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="text-xs font-medium">{mem.toFixed(0)}%</p>
</div>
</div>
<div className="flex flex-col">
<p className=" text-xs text-muted-foreground">{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="text-xs font-medium">{swap.toFixed(0)}%</p>
</div>
</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 }: { data: NezhaAPISafe }) {
const t = useTranslations("ServerDetailChartClient");
const [diskChartData, setDiskChartData] = useState([] as diskChartData[]);
const { disk } = formatNezhaInfo(data);
useEffect(() => {
if (data) {
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 > 30) {
newData.shift();
}
setDiskChartData(newData);
}
}, [data]);
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="text-md font-medium">{t("Disk")}</p>
<section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium">
{disk.toFixed(0)}%
</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={disk}
primaryColor="hsl(var(--chart-5))"
/>
</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 }: { data: NezhaAPISafe }) {
const t = useTranslations("ServerDetailChartClient");
const [networkChartData, setNetworkChartData] = useState(
[] as networkChartData[],
);
const { up, down } = formatNezhaInfo(data);
useEffect(() => {
if (data) {
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 > 30) {
newData.shift();
}
setNetworkChartData(newData);
}
}, [data]);
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 flex-col w-20">
<p className="text-xs text-muted-foreground">{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))]"></span>
<p className="text-xs font-medium">{up.toFixed(2)} M/s</p>
</div>
</div>
<div className="flex flex-col w-20">
<p className=" text-xs text-muted-foreground">
{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))]"></span>
<p className="text-xs font-medium">{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 }: { data: NezhaAPISafe }) {
const [connectChartData, setConnectChartData] = useState(
[] as connectChartData[],
);
const { tcp, udp } = formatNezhaInfo(data);
useEffect(() => {
if (data) {
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 > 30) {
newData.shift();
}
setConnectChartData(newData);
}
}, [data]);
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 flex-col w-12">
<p className="text-xs text-muted-foreground">TCP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]"></span>
<p className="text-xs font-medium">{tcp}</p>
</div>
</div>
<div className="flex flex-col w-12">
<p className=" text-xs text-muted-foreground">UDP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]"></span>
<p className="text-xs font-medium">{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

@ -1,200 +0,0 @@
"use client";
import { ServerDetailLoading } from "@/app/(main)/ClientComponents/ServerDetailLoading";
import { NezhaAPISafe, ServerApi } from "@/app/types/nezha-api";
import { BackIcon } from "@/components/Icon";
import ServerFlag from "@/components/ServerFlag";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import getEnv from "@/lib/env-entry";
import { cn, formatBytes, nezhaFetcher } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
export default function ServerDetailClient({
server_id,
}: {
server_id: number;
}) {
const t = useTranslations("ServerDetailClient");
const router = useRouter();
const [hasHistory, setHasHistory] = useState(false);
useEffect(() => {
window.scrollTo(0, 0);
}, []);
useEffect(() => {
const previousPath = sessionStorage.getItem("fromMainPage");
if (previousPath) {
setHasHistory(true);
}
}, []);
const linkClick = () => {
if (hasHistory) {
router.back();
} else {
router.push(`/`);
}
};
const { data: allFallbackData } = useSWRImmutable<ServerApi>(
"/api/server",
nezhaFetcher,
);
const fallbackData = allFallbackData?.result?.find(
(item) => item.id === server_id,
);
const { data, error } = useSWR<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);
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("detail_fetch_error_message")}
</p>
</div>
</>
);
}
if (!data) return <ServerDetailLoading />;
return (
<div>
<div
onClick={linkClick}
className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl"
>
<BackIcon />
{data?.name}
</div>
<section className="flex flex-wrap gap-2 mt-3">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("status")}</p>
<Badge
className={cn(
"text-[9px] rounded-[6px] w-fit px-1 py-0 -mt-[0.3px] dark:text-white",
{
" bg-green-800": data?.online_status,
" bg-red-600": !data?.online_status,
},
)}
>
{data?.online_status ? t("Online") : t("Offline")}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Uptime")}</p>
<div className="text-xs">
{" "}
{(data?.status.Uptime / 86400).toFixed(0)} {t("Days")}{" "}
</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Version")}</p>
<div className="text-xs">{data?.host.Version || "Unknown"} </div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Arch")}</p>
<div className="text-xs">{data?.host.Arch || "Unknown"} </div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Mem")}</p>
<div className="text-xs">{formatBytes(data?.host.MemTotal)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Disk")}</p>
<div className="text-xs">{formatBytes(data?.host.DiskTotal)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Region")}</p>
<section className="flex items-start gap-1">
<div className="text-xs text-start">
{data?.host.CountryCode.toUpperCase()}
</div>
<ServerFlag
className="text-[11px] -mt-[1px]"
country_code={data?.host.CountryCode}
/>
</section>
</section>
</CardContent>
</Card>
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("System")}</p>
{data?.host.Platform ? (
<div className="text-xs">
{" "}
{data?.host.Platform || "Unknown"} -{" "}
{data?.host.PlatformVersion}{" "}
</div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
{data?.host.CPU ? (
<div className="text-xs"> {data?.host.CPU}</div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
</section>
</div>
);
}

View File

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

View File

@ -1,147 +0,0 @@
"use client";
import { ServerApi } from "@/app/types/nezha-api";
import ServerCard from "@/components/ServerCard";
import Switch from "@/components/Switch";
import getEnv from "@/lib/env-entry";
import { useFilter } from "@/lib/network-filter-context";
import { useStatus } from "@/lib/status-context";
import { nezhaFetcher } from "@/lib/utils";
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
export default function ServerListClient() {
const { status } = useStatus();
const { filter } = useFilter();
const t = useTranslations("ServerListClient");
const containerRef = useRef<HTMLDivElement>(null);
const defaultTag = "defaultTag";
const router = useRouter();
const [tag, setTag] = useState<string>(defaultTag);
useEffect(() => {
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 } = 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?.result) 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 filteredServersByStatus =
status === "all"
? sortedServers
: sortedServers.filter((server) =>
[status].includes(server.online_status ? "online" : "offline"),
);
const allTag = filteredServersByStatus
.map((server) => server.tag)
.filter(Boolean);
const uniqueTags = [...new Set(allTag)];
uniqueTags.unshift(defaultTag);
const filteredServers =
tag === defaultTag
? filteredServersByStatus
: filteredServersByStatus.filter((server) => server.tag === tag);
if (filter) {
// 根据使用流量进行从高到低排序
filteredServers.sort((a, b) => {
return (
b.status.NetInTransfer +
b.status.NetOutTransfer -
(a.status.NetInTransfer + b.status.NetOutTransfer)
);
});
}
const tagCountMap: Record<string, number> = {};
filteredServersByStatus.forEach((server) => {
if (server.tag) {
tagCountMap[server.tag] = (tagCountMap[server.tag] || 0) + 1;
}
});
return (
<>
<section className="flex items-center gap-2 w-full overflow-hidden">
<button
onClick={() => {
router.push(`/?global=true`);
}}
className="rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 hover:bg-blue-500 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] hover:shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] "
>
<GlobeAsiaAustraliaIcon className="size-[13px]" />
</button>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
<Switch
allTag={uniqueTags}
nowTag={tag}
tagCountMap={tagCountMap}
onTagChange={handleTagChange}
/>
)}
</section>
<section
ref={containerRef}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
{filteredServers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
</>
);
}

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

View File

@ -1,41 +1,35 @@
"use client";
"use client"
import { ServerApi } from "@/app/types/nezha-api";
import { Loader } from "@/components/loading/Loader";
import { Card, CardContent } from "@/components/ui/card";
import getEnv from "@/lib/env-entry";
import { useFilter } from "@/lib/network-filter-context";
import { useStatus } from "@/lib/status-context";
import { cn, formatBytes, nezhaFetcher } from "@/lib/utils";
import blogMan from "@/public/blog-man.webp";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
import { useFilter } from "@/app/context/network-filter-context"
import { useServerData } from "@/app/context/server-data-context"
import { useStatus } from "@/app/context/status-context"
import AnimateCountClient from "@/components/AnimatedCount"
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { cn, formatBytes } from "@/lib/utils"
import blogMan from "@/public/blog-man.webp"
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid"
import { useTranslations } from "next-intl"
import Image from "next/image"
export default function ServerOverviewClient() {
const { status, setStatus } = useStatus();
const { filter, setFilter } = useFilter();
const t = useTranslations("ServerOverviewClient");
const { data, error, isLoading } = useSWR<ServerApi>(
"/api/server",
nezhaFetcher,
);
const disableCartoon = getEnv("NEXT_PUBLIC_DisableCartoon") === "true";
const searchParams = useSearchParams();
const global = searchParams.get("global");
const { data, error, isLoading } = useServerData()
const { status, setStatus } = useStatus()
const { filter, setFilter } = useFilter()
const t = useTranslations("ServerOverviewClient")
const disableCartoon = getEnv("NEXT_PUBLIC_DisableCartoon") === "true"
if (error) {
const errorInfo = error as any
return (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">
Error status:{error.status} {error.info?.cause ?? error.message}
<p className="font-medium text-sm opacity-40">
Error status:{errorInfo?.status} {errorInfo.info?.cause ?? errorInfo?.message}
</p>
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
<p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div>
);
)
}
return (
@ -43,27 +37,21 @@ export default function ServerOverviewClient() {
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card
onClick={() => {
if (!global) {
setFilter(false);
setStatus("all");
}
setFilter(false)
setStatus("all")
}}
className={cn("cursor-pointer hover:border-blue-500 transition-all", {
"pointer-events-none": global,
})}
className={cn("group cursor-pointer transition-all hover:border-blue-500")}
>
<CardContent className="px-6 py-3">
<CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{t("p_816-881_Totalservers")}
</p>
<div className="flex items-center gap-2">
<p className="font-medium text-sm md:text-base">{t("p_816-881_Totalservers")}</p>
<div className="flex min-h-[28px] items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
{data?.result ? (
<div className="text-lg font-semibold">
{data?.result.length}
<div className="font-semibold text-lg">
<AnimateCountClient count={data?.result.length} />
</div>
) : (
<div className="flex h-7 items-center">
@ -76,34 +64,27 @@ export default function ServerOverviewClient() {
</Card>
<Card
onClick={() => {
if (!global) {
setFilter(false);
setStatus("online");
}
setFilter(false)
setStatus("online")
}}
className={cn(
"cursor-pointer hover:ring-green-500 ring-1 ring-transparent transition-all",
"cursor-pointer ring-1 ring-transparent transition-all hover:ring-green-500",
{
"ring-green-500 ring-2 border-transparent": status === "online",
},
{
"pointer-events-none": global,
"border-transparent ring-2 ring-green-500": status === "online",
},
)}
>
<CardContent className="px-6 py-3">
<CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{t("p_1610-1676_Onlineservers")}
</p>
<div className="flex items-center gap-2">
<p className="font-medium text-sm md:text-base">{t("p_1610-1676_Onlineservers")}</p>
<div className="flex min-h-[28px] items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
{data?.result ? (
<div className="text-lg font-semibold">
{data?.live_servers}
<div className="font-semibold text-lg">
<AnimateCountClient count={data?.live_servers} />
</div>
) : (
<div className="flex h-7 items-center">
@ -116,34 +97,27 @@ export default function ServerOverviewClient() {
</Card>
<Card
onClick={() => {
if (!global) {
setFilter(false);
setStatus("offline");
}
setFilter(false)
setStatus("offline")
}}
className={cn(
"cursor-pointer hover:ring-red-500 ring-1 ring-transparent transition-all",
"cursor-pointer ring-1 ring-transparent transition-all hover:ring-red-500",
{
"ring-red-500 ring-2 border-transparent": status === "offline",
},
{
"pointer-events-none": global,
"border-transparent ring-2 ring-red-500": status === "offline",
},
)}
>
<CardContent className="px-6 py-3">
<CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{t("p_2532-2599_Offlineservers")}
</p>
<div className="flex items-center gap-2">
<p className="font-medium text-sm md:text-base">{t("p_2532-2599_Offlineservers")}</p>
<div className="flex min-h-[28px] items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
</span>
{data?.result ? (
<div className="text-lg font-semibold">
{data?.offline_servers}
<div className="font-semibold text-lg">
<AnimateCountClient count={data?.offline_servers} />
</div>
) : (
<div className="flex h-7 items-center">
@ -156,44 +130,51 @@ export default function ServerOverviewClient() {
</Card>
<Card
onClick={() => {
if (!global) {
setStatus("all");
setFilter(true);
}
setStatus("all")
setFilter(true)
}}
className={cn(
"cursor-pointer hover:ring-purple-500 ring-1 ring-transparent transition-all",
"group cursor-pointer ring-1 ring-transparent transition-all hover:ring-purple-500",
{
"ring-purple-500 ring-2 border-transparent": filter === true,
},
{
"pointer-events-none": global,
"border-transparent ring-2 ring-purple-500": filter === true,
},
)}
>
<CardContent className="relative px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{t("p_3463-3530_Totalbandwidth")}
</p>
<CardContent className="relative flex h-full items-center px-6 py-3">
<section className="flex w-full flex-col gap-1">
<div className="flex w-full items-center justify-between">
<p className="font-medium text-sm md:text-base">{t("network")}</p>
</div>
{data?.result ? (
<section className="flex flex-col sm:flex-row pt-[8px] sm:items-center items-start gap-1">
<p className="text-[12px] text-nowrap font-semibold">
{formatBytes(data?.total_out_bandwidth)}
</p>
<p className="text-[12px] text-nowrap font-semibold">
{formatBytes(data?.total_in_bandwidth)}
</p>
</section>
<>
<section className="flex flex-row flex-wrap items-start gap-1 pr-0">
<p className="text-nowrap font-medium text-[12px] text-blue-800 dark:text-blue-400">
{formatBytes(data?.total_out_bandwidth)}
</p>
<p className="text-nowrap font-medium text-[12px] text-purple-800 dark:text-purple-400">
{formatBytes(data?.total_in_bandwidth)}
</p>
</section>
<section className="-mr-1 flex flex-row flex-wrap items-start gap-1 sm:items-center">
<p className="flex items-center text-nowrap font-semibold text-[11px]">
<ArrowUpCircleIcon className="mr-0.5 size-3 sm:mb-[1px]" />
{formatBytes(data?.total_out_speed)}/s
</p>
<p className="flex items-center text-nowrap font-semibold text-[11px]">
<ArrowDownCircleIcon className="mr-0.5 size-3" />
{formatBytes(data?.total_in_speed)}/s
</p>
</section>
</>
) : (
<div className="flex h-7 items-center">
<div className="flex h-[38px] items-center">
<Loader visible={true} />
</div>
)}
</section>
{!disableCartoon && (
<Image
className="pointer-events-none absolute right-3 top-[-85px] z-10 w-20 scale-90 md:scale-100"
className="absolute top-[-85px] right-3 z-50 w-20 scale-90 transition-all group-hover:opacity-50 md:scale-100"
alt={"Hamster1963"}
src={blogMan}
priority
@ -205,9 +186,9 @@ export default function ServerOverviewClient() {
</section>
{data?.result === undefined && !isLoading && (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
<p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div>
)}
</>
);
)
}

View File

@ -1,42 +0,0 @@
"use client";
import { NetworkChartClient } from "@/app/(main)/ClientComponents/NetworkChart";
import ServerDetailChartClient from "@/app/(main)/ClientComponents/ServerDetailChartClient";
import ServerDetailClient from "@/app/(main)/ClientComponents/ServerDetailClient";
import TabSwitch from "@/components/TabSwitch";
import { Separator } from "@/components/ui/separator";
import { use, useState } from "react";
export default function Page(props: { params: Promise<{ id: string }> }) {
const params = use(props.params);
const tabs = ["Detail", "Network"];
const [currentTab, setCurrentTab] = useState(tabs[0]);
return (
<div className="mx-auto grid w-full max-w-5xl gap-2">
<ServerDetailClient server_id={Number(params.id)} />
<section className="flex items-center my-2 w-full">
<Separator className="flex-1" />
<div className="flex justify-center w-full max-w-[200px]">
<TabSwitch
tabs={tabs}
currentTab={currentTab}
setCurrentTab={setCurrentTab}
/>
</div>
<Separator className="flex-1" />
</section>
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
<ServerDetailChartClient
server_id={Number(params.id)}
show={currentTab === tabs[0]}
/>
</div>
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
<NetworkChartClient
server_id={Number(params.id)}
show={currentTab === tabs[1]}
/>
</div>
</div>
);
}

View File

@ -1,35 +1,58 @@
import pack from "@/package.json";
import { useTranslations } from "next-intl";
"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 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 w-full max-w-5xl">
<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 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
<p className={`mt-3 flex gap-1 ${baseTextStyles}`}>
{t("p_146-598_Findthecodeon")}{" "}
<a
href="https://github.com/hamster1963/nezha-dash"
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 hover:decoration-yellow-300 transition-colors decoration-2 underline-offset-2 dark:decoration-yellow-900 dark:hover:decoration-yellow-600"
>
{t("a_303-585_GitHub")}
</a>
<a
href={`https://github.com/hamster1963/nezha-dash/releases/tag/v${version}`}
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 hover:decoration-yellow-300 transition-colors decoration-2 underline-offset-2 dark:decoration-yellow-900 dark:hover:decoration-yellow-600"
>
v{version}
</a>
<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 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
<section className={`mt-1 flex items-center gap-2 ${baseTextStyles}`}>
{t("section_607-869_2020")}
{new Date().getFullYear()}{" "}
<a href={"https://buycoffee.top"}>{t("a_800-850_Hamster1963")}</a>
{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>
);
)
}

View File

@ -1,32 +1,125 @@
"use client";
"use client"
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 React, { useEffect, useRef, useState } from "react";
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 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 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={() => {
sessionStorage.removeItem("selectedTag");
router.push(`/`);
}}
className="flex cursor-pointer items-center text-base font-medium"
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
@ -34,111 +127,36 @@ function Header() {
height={40}
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! dark:hidden"
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! border-2 border-transparent h-6 w-6 object-cover object-top p-0! hidden dark:block"
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 text-sm font-medium opacity-40 md:block">
{customDescription
? customDescription
: t("p_1079-1199_Simpleandbeautifuldashbo")}
<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">
<Links />
<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>
);
)
}
type links = {
link: string;
name: string;
};
function Links() {
const linksEnv = getEnv("NEXT_PUBLIC_Links");
const links: links[] | null = linksEnv ? JSON.parse(linksEnv) : null;
if (!links) return null;
return (
<div className="flex items-center gap-2">
{links.map((link, index) => {
return (
<a
key={index}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm font-medium opacity-50 transition-opacity hover:opacity-100"
>
{link.name}
</a>
);
})}
</div>
);
}
// https://github.com/streamich/react-use/blob/master/src/useInterval.ts
const useInterval = (callback: () => void, delay: number | null) => {
const savedCallback = useRef<() => void>(() => {});
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="text-sm font-medium">{timeString}</p>
) : (
<Skeleton className="h-[20px] w-[50px] rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
)}
</div>
</section>
);
}
export default Header;
export default Header

View File

@ -1,31 +1,38 @@
import Footer from "@/app/(main)/footer";
import Header from "@/app/(main)/header";
import { auth } from "@/auth";
import { SignIn } from "@/components/SignIn";
import getEnv from "@/lib/env-entry";
import React from "react";
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;
};
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>{children}</AuthProtected>
<AuthProtected>
<ServerDataProvider>
{children}
<DashCommand />
</ServerDataProvider>
</AuthProtected>
<Footer />
</main>
</div>
);
)
}
async function AuthProtected({ children }: DashboardProps) {
if (getEnv("SitePassword")) {
const session = await auth();
const session = await auth()
if (!session) {
return <SignIn />;
return <SignIn />
}
}
return children;
return children
}

View File

@ -1,25 +1,11 @@
import ServerList from "@/components/ServerList";
import ServerOverview from "@/components/ServerOverview";
import { Suspense } from "react";
import ServerListClient from "@/app/(main)/ClientComponents/main/ServerListClient"
import ServerOverviewClient from "@/app/(main)/ClientComponents/main/ServerOverviewClient"
import ServerGlobal from "./ClientComponents/Global";
import GlobalLoading from "./ClientComponents/GlobalLoading";
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const global = (await searchParams).global;
export default async function Home() {
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverview />
{!global && <ServerList />}
{global && (
<Suspense fallback={<GlobalLoading />}>
<ServerGlobal />
</Suspense>
)}
<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,3 +1,3 @@
import { handlers } from "@/auth";
import { handlers } from "@/auth"
export const { GET, POST } = handlers;
export const { GET, POST } = handlers

View File

@ -1,50 +1,44 @@
import { auth } from "@/auth";
import getEnv from "@/lib/env-entry";
import { GetServerDetail } from "@/lib/serverFetch";
import { redirect } from "next/navigation";
import { NextRequest, NextResponse } from "next/server";
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";
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number;
message: string;
statusCode: number
message: string
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth();
const session = await auth()
if (!session) {
redirect("/");
redirect("/")
}
}
const { searchParams } = new URL(req.url);
const server_id = searchParams.get("server_id");
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 })
}
try {
const serverIdNum = parseInt(server_id, 10);
if (isNaN(serverIdNum)) {
return NextResponse.json(
{ error: "server_id must be a valid number" },
{ status: 400 },
);
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 });
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 });
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,52 +1,46 @@
import { auth } from "@/auth";
import getEnv from "@/lib/env-entry";
import { GetServerMonitor } from "@/lib/serverFetch";
import { redirect } from "next/navigation";
import { NextRequest, 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 ResError extends Error {
statusCode: number;
message: string;
statusCode: number
message: string
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth();
const session = await auth()
if (!session) {
redirect("/");
redirect("/")
}
}
const { searchParams } = new URL(req.url);
const server_id = searchParams.get("server_id");
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 })
}
try {
const serverIdNum = parseInt(server_id, 10);
if (isNaN(serverIdNum)) {
return NextResponse.json(
{ error: "server_id must be a number" },
{ status: 400 },
);
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 });
})
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 });
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

@ -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,32 +1,32 @@
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";
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 ResError extends Error {
statusCode: number;
message: string;
statusCode: number
message: string
}
export async function GET() {
if (getEnv("SitePassword")) {
const session = await auth();
const session = await auth()
if (!session) {
redirect("/");
redirect("/")
}
}
try {
const data = await GetNezhaData();
return NextResponse.json(data, { status: 200 });
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 });
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

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

View File

@ -1,27 +1,26 @@
// @auto-i18n-check. Please do not delete the line.
import { MotionProvider } from "@/components/motion/motion-provider";
import getEnv from "@/lib/env-entry";
import { FilterProvider } from "@/lib/network-filter-context";
import { StatusProvider } from "@/lib/status-context";
import { cn } from "@/lib/utils";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { 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 React from "react";
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");
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",
@ -30,28 +29,28 @@ export const metadata: Metadata = {
appleWebApp: {
capable: true,
title: customTitle || "NezhaDash",
statusBarStyle: "black-translucent",
statusBarStyle: "default",
},
robots: {
index: disableIndex ? false : true,
follow: disableIndex ? false : true,
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;
children: React.ReactNode
}) {
const locale = await getLocale();
const messages = await getMessages();
const locale = await getLocale()
const messages = await getMessages()
return (
<html lang={locale} suppressHydrationWarning>
@ -59,34 +58,30 @@ export default async function LocaleLayout({
<PublicEnvScript />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
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,
)}
>
<MotionProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider messages={messages}>
<FilterProvider>
<StatusProvider>{children}</StatusProvider>
</FilterProvider>
</NextIntlClientProvider>
</ThemeProvider>
</MotionProvider>
<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>
);
)
}

View File

@ -1,31 +1,22 @@
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
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");
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")}
<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>
</div>
</div>
</main>
);
</section>
<Footer />
</main>
</div>
)
}

View File

@ -1,75 +1,77 @@
export type ServerApi = {
live_servers: number;
offline_servers: number;
total_out_bandwidth: number;
total_in_bandwidth: number;
result: NezhaAPISafe[];
};
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 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;
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;
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;
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;
}[];
};
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[];
monitor_id: number
monitor_name: string
server_id: number
server_name: string
created_at: number[]
avg_delay: number[]
}

View File

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

26
auth.ts
View File

@ -1,10 +1,12 @@
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import getEnv from "./lib/env-entry";
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 ?? "this_is_nezha_dash_web_secret",
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({
@ -12,21 +14,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
credentials: { password: { label: "Password", type: "password" } },
// authorization function
async authorize(credentials) {
const { password } = credentials;
const { password } = credentials
if (password === getEnv("SitePassword")) {
return { id: "nezha-dash-auth" };
return { id: "nezha-dash-auth" }
}
return { error: "Invalid password" };
return { error: "Invalid password" }
},
}),
],
callbacks: {
async signIn({ user }) {
// @ts-ignore
// @ts-expect-error user is not null
if (user.error) {
return false;
return false
}
return true;
return 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

@ -1,2 +0,0 @@
[install]
registry = "https://registry.npmmirror.com/"

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-sm(${blurAmount}px)`,
WebkitBackdropFilter: `blur-sm(${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>
</>
)
}

View File

@ -1,31 +0,0 @@
"use client";
import { useFilter } from "@/lib/network-filter-context";
import { useStatus } from "@/lib/status-context";
import { ServerStackIcon } from "@heroicons/react/20/solid";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function GlobalBackButton() {
const router = useRouter();
const { setStatus } = useStatus();
const { setFilter } = useFilter();
useEffect(() => {
setStatus("all");
setFilter(false);
sessionStorage.removeItem("selectedTag");
router.prefetch(`/`);
}, []);
return (
<button
onClick={() => {
router.push(`/`);
}}
className="rounded-[50px] mt-[1px] w-fit text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-green-600 hover:bg-green-500 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] hover:shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] "
>
<ServerStackIcon className="size-[13px]" />
</button>
);
}

View File

@ -1,11 +1,11 @@
import Image from "next/image";
import Image from "next/image"
export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 496 512" fill="white" {...props}>
<svg role="img" aria-label="github-icon" viewBox="0 0 496 512" fill="white" {...props}>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
);
)
}
export function BackIcon() {
@ -30,5 +30,5 @@ export function BackIcon() {
height="20"
/>
</>
);
)
}

View File

@ -1,25 +1,25 @@
"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 { localeItems } from "@/i18n-metadata";
import { setUserLocale } from "@/i18n/locale";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { useLocale } from "next-intl";
import * as React from "react";
} 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 locale = useLocale()
const handleSelect = (e: Event, newLocale: string) => {
e.preventDefault(); // 阻止默认的关闭行为
setUserLocale(newLocale);
};
e.preventDefault() // 阻止默认的关闭行为
setUserLocale(newLocale)
}
return (
<DropdownMenu>
@ -27,24 +27,32 @@ export function LanguageSwitcher() {
<Button
variant="outline"
size="sm"
className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50 dark:hover:bg-accent/50"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
{localeItems.find((item) => item.code === locale)?.name}
<LanguageIcon className="size-4" />
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
{localeItems.map((item) => (
{localeItems.map((item, index) => (
<DropdownMenuItem
key={item.code}
onSelect={(e) => handleSelect(e, item.code)}
className={locale === item.code ? "bg-muted gap-3" : ""}
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}{" "}
{locale === item.code && <CheckCircleIcon className="size-4" />}
{item.name} {locale === item.code && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
)
}

View File

@ -1,43 +1,36 @@
import { 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";
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 t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo);
formatNezhaInfo(serverInfo)
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true";
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true";
const fixedTopServerName =
getEnv("NEXT_PUBLIC_FixedTopServerName") === "true";
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true");
};
sessionStorage.setItem("fromMainPage", "true")
}
return online ? (
<Link onClick={saveSession} href={`/${id}`} prefetch={true}>
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 cursor-pointer hover:bg-accent/50 transition-colors",
"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,
@ -50,7 +43,7 @@ export default function ServerCard({
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500 self-center"></span>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
@ -77,12 +70,8 @@ export default function ServerCard({
})}
>
{fixedTopServerName && (
<div
className={
"hidden col-span-1 items-center lg:flex lg:flex-row gap-2"
}
>
<div className="text-xs font-semibold">
<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]" />
) : (
@ -90,69 +79,52 @@ export default function ServerCard({
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("System")}</p>
<div className="flex items-center text-[10.5px] font-semibold">
{host.Platform.includes("Windows")
? "Windows"
: GetOsName(host.Platform)}
<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-xs text-muted-foreground">{t("CPU")}</p>
<div className="flex items-center text-xs font-semibold">
{cpu.toFixed(2)}%
</div>
<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-xs text-muted-foreground">{t("Mem")}</p>
<div className="flex items-center text-xs font-semibold">
{mem.toFixed(2)}%
</div>
<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-xs text-muted-foreground">{t("STG")}</p>
<div className="flex items-center text-xs font-semibold">
{stg.toFixed(2)}%
</div>
<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-xs text-muted-foreground">{t("Upload")}</p>
<div className="flex items-center text-xs font-semibold">
{up >= 1024
? `${(up / 1024).toFixed(2)}G/s`
: `${up.toFixed(2)}M/s`}
<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-xs text-muted-foreground">{t("Download")}</p>
<div className="flex items-center text-xs font-semibold">
{down >= 1024
? `${(down / 1024).toFixed(2)}G/s`
: `${down.toFixed(2)}M/s`}
<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
onClick={() => {
router.push(`/${id}`);
}}
className={"flex items-center justify-between gap-1"}
>
<section className={"flex items-center justify-between gap-1"}>
<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"
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="items-center flex-1 justify-center rounded-[8px] text-nowrap text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
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>
@ -162,44 +134,41 @@ export default function ServerCard({
</Card>
</Link>
) : (
<Card
className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5",
showNetTransfer
? "lg:min-h-[91px] min-h-[123px]"
: "lg:min-h-[61px] min-h-[93px]",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
<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,
},
)}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500 self-center"></span>
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<p
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs" : "text-sm",
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{name}
</p>
</div>
</section>
</Card>
);
{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")}
>
{name}
</p>
</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/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,48 +1,44 @@
import getEnv from "@/lib/env-entry";
import { cn } from "@/lib/utils";
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,
className,
}: {
country_code: string;
className?: string;
country_code: string
className?: string
}) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false);
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={cn("text-[12px] text-muted-foreground", className)}>
@ -52,5 +48,5 @@ export default function ServerFlag({
getUnicodeFlagIcon(country_code)
)}
</span>
);
)
}

View File

@ -1,5 +0,0 @@
import ServerListClient from "@/app/(main)/ClientComponents/ServerListClient";
export default async function ServerList() {
return <ServerListClient />;
}

View File

@ -1,5 +0,0 @@
import ServerOverviewClient from "@/app/(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"}
/>
);
)
}

View File

@ -1,79 +1,74 @@
"use client";
"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 { 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";
import { Loader } from "./loading/Loader"
export function SignIn() {
const t = useTranslations("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 [csrfToken, setCsrfToken] = useState("")
const [loading, setLoading] = useState(false)
const [errorState, setErrorState] = useState(false)
const [successState, setSuccessState] = useState(false)
const router = useRouter();
const router = useRouter()
useEffect(() => {
async function loadProviders() {
const csrf = await getCsrfToken();
setCsrfToken(csrf);
const csrf = await getCsrfToken()
setCsrfToken(csrf)
}
loadProviders();
}, []);
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;
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);
console.log("login error")
setErrorState(true)
setSuccessState(false)
} else {
console.log("login success");
setErrorState(false);
setSuccessState(true);
router.push("/");
router.refresh();
console.log("login success")
setErrorState(false)
setSuccessState(true)
router.push("/")
router.refresh()
}
setLoading(false);
};
setLoading(false)
}
return (
<form
className="flex flex-col items-center justify-start gap-4 p-4 "
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="text-red-500 text-sm font-semibold">
{t("ErrorMessage")}
</p>
)}
{errorState && <p className="font-semibold text-red-500 text-sm">{t("ErrorMessage")}</p>}
{successState && (
<p className="text-green-500 text-sm font-semibold">
{t("SuccessMessage")}
</p>
<p className="font-semibold text-green-500 text-sm">{t("SuccessMessage")}</p>
)}
<p className="text-base font-semibold">{t("SignInMessage")}</p>
<p className="font-semibold text-base">{t("SignInMessage")}</p>
<input
className="px-1 border-[1px] rounded-[5px] border-stone-300 dark:border-stone-800"
className="rounded-[5px] border-[1px] border-stone-300 px-1 dark:border-stone-800"
name="password"
type="password"
/>
</label>
<button
className="px-1.5 py-0.5 w-fit flex items-center gap-1 text-sm font-semibold border-stone-300 dark:border-stone-800 rounded-[8px] border bg-card hover:brightness-95 transition-all text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none"
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")}
@ -81,5 +76,5 @@ export function SignIn() {
</button>
</section>
</form>
);
)
}

View File

@ -1,10 +1,9 @@
"use client";
"use client"
import getEnv from "@/lib/env-entry";
import { cn } from "@/lib/utils";
import { m } from "framer-motion";
import { useTranslations } from "next-intl";
import React, { createRef, useEffect, useRef } 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,
@ -12,94 +11,121 @@ export default function Switch({
tagCountMap,
onTagChange,
}: {
allTag: string[];
nowTag: string;
tagCountMap: Record<string, number>;
onTagChange: (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 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");
const savedTag = sessionStorage.getItem("selectedTag")
if (savedTag && allTag.includes(savedTag)) {
onTagChange(savedTag);
onTagChange(savedTag)
}
}, [allTag, onTagChange]);
}, [allTag, onTagChange])
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const container = scrollRef.current
if (!container) return
const isOverflowing = container.scrollWidth > container.clientWidth;
if (!isOverflowing) return;
const isOverflowing = container.scrollWidth > container.clientWidth
if (!isOverflowing) return
const onWheel = (e: WheelEvent) => {
e.preventDefault();
container.scrollLeft += e.deltaY;
};
e.preventDefault()
container.scrollLeft += e.deltaY
}
container.addEventListener("wheel", onWheel, { passive: false });
container.addEventListener("wheel", onWheel, { passive: false })
return () => {
container.removeEventListener("wheel", onWheel);
};
}, []);
container.removeEventListener("wheel", onWheel)
}
}, [])
useEffect(() => {
const currentTagRef = tagRefs.current[allTag.indexOf(nowTag)];
if (currentTagRef && currentTagRef.current) {
currentTagRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
if (currentTagElement) {
setIndicator({
x: currentTagElement.offsetLeft,
w: currentTagElement.offsetWidth,
})
}
}, [nowTag]);
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
ref={scrollRef}
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">
<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}
ref={tagRefs.current[index]}
onClick={() => onTagChange(tag)}
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 && (
<m.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">
<div className="whitespace-nowrap flex items-center gap-2">
<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 px-1.5 rounded-full bg-muted">
{tagCountMap[tag]}
</div>
)}
{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>
);
)
}

View File

@ -1,44 +1,69 @@
"use client";
"use client"
import { cn } from "@/lib/utils";
import { m } from "framer-motion";
import { useTranslations } from "next-intl";
import React from "react";
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;
tabs: string[]
currentTab: string
setCurrentTab: (tab: string) => void
}) {
const t = useTranslations("TabSwitch");
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="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{tabs.map((tab: string) => (
<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] text-[13px] font-[600] transition-all duration-500",
currentTab === tab
? "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": currentTab === tab,
},
)}
>
{currentTab === tab && (
<m.div
layoutId="tab-switch"
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">{t(tab)}</p>
</div>
@ -46,5 +71,5 @@ export default function TabSwitch({
))}
</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,26 +1,31 @@
"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 { cn } from "@/lib/utils";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { Moon, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
} 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, theme } = useTheme();
const t = useTranslations("ThemeSwitcher");
const { setTheme, theme } = useTheme()
const t = useTranslations("ThemeSwitcher")
const handleSelect = (e: Event, newTheme: string) => {
e.preventDefault();
setTheme(newTheme);
};
const handleSelect = (newTheme: string) => {
setTheme(newTheme)
}
const id = useId()
return (
<DropdownMenu>
@ -28,36 +33,48 @@ export function ModeToggle() {
<Button
variant="outline"
size="sm"
className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50 dark:hover:bg-accent/50"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<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 className="flex flex-col gap-0.5" align="end">
<DropdownMenuItem
className={cn({ "gap-3 bg-muted": theme === "light" })}
onSelect={(e) => handleSelect(e, "light")}
>
{t("Light")}{" "}
{theme === "light" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
<DropdownMenuItem
className={cn({ "gap-3 bg-muted": theme === "dark" })}
onSelect={(e) => handleSelect(e, "dark")}
>
{t("Dark")}{" "}
{theme === "dark" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
<DropdownMenuItem
className={cn({ "gap-3 bg-muted": theme === "system" })}
onSelect={(e) => handleSelect(e, "system")}
>
{t("System")}{" "}
{theme === "system" && <CheckCircleIcon className="size-4" />}
</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

@ -1,5 +1,5 @@
import { Loader } from "@/components/loading/Loader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function NetworkChartLoading() {
return (
@ -7,17 +7,17 @@ export default function NetworkChartLoading() {
<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"></div>
<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="mt-[2px] aspect-auto h-[14px] w-32 bg-muted" />
</div>
<div className="hidden pr-4 pt-4 sm:block">
<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"></div>
<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

@ -1 +0,0 @@
export { domMax as default } from "framer-motion";

View File

@ -1,14 +0,0 @@
"use client";
import { LazyMotion } from "framer-motion";
const loadFeatures = () =>
import("./framer-lazy-feature").then((res) => res.default);
export const MotionProvider = ({ children }: { children: React.ReactNode }) => {
return (
<LazyMotion features={loadFeatures} strict key="framer">
{children}
</LazyMotion>
);
};

View File

@ -1,11 +1,11 @@
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
interface Props {
max: number;
value: number;
min: number;
className?: string;
primaryColor?: string;
max: number
value: number
min: number
className?: string
primaryColor?: string
}
export default function AnimatedCircularProgressBar({
@ -15,13 +15,13 @@ export default function AnimatedCircularProgressBar({
primaryColor,
className,
}: Props) {
const circumference = 2 * Math.PI * 45;
const percentPx = circumference / 100;
const currentPercent = ((value - min) / (max - min)) * 100;
const circumference = 2 * Math.PI * 45
const percentPx = circumference / 100
const currentPercent = ((value - min) / (max - min)) * 100
return (
<div
className={cn("relative size-40 text-2xl font-semibold", className)}
className={cn("relative size-40 font-semibold text-2xl", className)}
style={
{
"--circle-size": "100px",
@ -37,12 +37,8 @@ export default function AnimatedCircularProgressBar({
} as React.CSSProperties
}
>
<svg
fill="none"
className="size-full"
strokeWidth="2"
viewBox="0 0 100 100"
>
<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"
@ -52,7 +48,7 @@ export default function AnimatedCircularProgressBar({
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100 stroke-muted"
className="stroke-muted opacity-100"
style={
{
"--stroke-percent": 90 - currentPercent,
@ -62,8 +58,7 @@ export default function AnimatedCircularProgressBar({
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)",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
@ -76,7 +71,7 @@ export default function AnimatedCircularProgressBar({
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("opacity-100 stroke-current", {
className={cn("stroke-current opacity-100", {
"stroke-[var(--stroke-primary-color)]": primaryColor,
})}
style={
@ -90,18 +85,17 @@ export default function AnimatedCircularProgressBar({
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)",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
className="fade-in absolute inset-0 m-auto size-fit animate-in delay-[var(--delay)] duration-[var(--transition-length)] ease-linear"
>
{currentPercent}
</span>
</div>
);
)
}

View File

@ -1,21 +1,20 @@
import Image from "next/image";
import Link from "next/link";
import React from "react";
import Image from "next/image"
import Link from "next/link"
export const AnimatedTooltip = ({
items,
}: {
items: {
id: number;
name: string;
designation: string;
image: string;
}[];
id: number
name: string
designation: string
image: string
}[]
}) => {
return (
<>
{items.map((item) => (
<div className="group relative -mr-4" key={item.name}>
<div className="group -mr-4 relative" key={item.name}>
<Link href="https://buycoffee.top" target="_blank">
<Image
width={40}
@ -29,5 +28,5 @@ export const AnimatedTooltip = ({
</div>
))}
</>
);
};
)
}

View File

@ -1,14 +1,13 @@
import { cn } from "@/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils"
import { type VariantProps, cva } from "class-variance-authority"
import type * as React from "react"
const badgeVariants = cva(
"inline-flex items-center text-nowarp rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors pointer-events-none focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
@ -20,16 +19,14 @@ const badgeVariants = cva(
variant: "default",
},
},
);
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants };
export { Badge, badgeVariants }

View File

@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
@ -9,12 +9,9 @@ const buttonVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
@ -30,26 +27,22 @@ const buttonVariants = cva(
size: "default",
},
},
);
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
},
);
Button.displayName = "Button";
)
Button.displayName = "Button"
export { Button, buttonVariants };
export { Button, buttonVariants }

View File

@ -1,85 +1,58 @@
import { cn } from "@/lib/utils";
import * as React from "react";
import { cn } from "@/lib/utils"
import * as React from "react"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
className,
)}
{...props}
/>
),
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold text-2xl leading-none tracking-tight", className)}
{...props}
/>
),
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
<p ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
)
CardFooter.displayName = "CardFooter"
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -1,49 +1,47 @@
"use client";
"use client"
import { cn } from "@/lib/utils";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k: string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
)
}
type ChartContextProps = {
config: ChartConfig;
};
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null);
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext);
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
throw new Error("useChart must be used within a <ChartContainer />")
}
return context;
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
config: ChartConfig
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
@ -57,22 +55,18 @@ const ChartContainer = React.forwardRef<
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null;
return null
}
return (
@ -84,10 +78,8 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
@ -96,20 +88,20 @@ ${colorConfig
.join("\n"),
}}
/>
);
};
)
}
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
@ -130,68 +122,65 @@ const ChartTooltipContent = React.forwardRef<
},
ref,
) => {
const { config } = useChart();
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
return null
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
)
}
if (!value) {
return null;
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
if (!active || !payload?.length) {
return null;
return null
}
payload.sort((a, b) => {
return Number(b.value) - Number(a.value);
});
return Number(b.value) - Number(a.value)
})
const nestLabel = payload.length === 1 && indicator !== "dot";
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-sm border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
"grid min-w-[8rem] items-start gap-1.5 overflow-hidden rounded-sm border border-border/50 bg-stone-100 text-xs dark:bg-stone-900",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{!nestLabel && (
<div className="-mb-1 mx-auto px-2.5 pt-1">{!nestLabel ? tooltipLabel : null}</div>
)}
<div
className={cn("grid gap-1.5 bg-white px-2.5 py-1.5 dark:bg-black", {
"border-t-[1px]": !nestLabel,
})}
>
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
@ -244,125 +233,108 @@ const ChartTooltipContent = React.forwardRef<
{item.value && (
<span
className={cn(
"ml-2 font-mono font-medium tabular-nums text-foreground",
"ml-2 font-medium text-foreground tabular-nums",
payload.length === 1 && "-ml-9",
)}
>
{typeof item.value === "number"
? item.value.toFixed(3).toLocaleString()
: item.value}
? item.value.toFixed(2).toLocaleString()
: item.value}{" "}
ms
</span>
)}
</div>
</>
)}
</div>
);
)
})}
</div>
</div>
);
)
},
);
ChartTooltipContent.displayName = "ChartTooltip";
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart()
if (!payload?.length) {
return null;
}
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex flex-wrap items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
ref={ref}
className={cn(
"flex flex-wrap items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{key}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{key}
</div>
)
})}
</div>
)
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
: undefined
let configLabelKey: string = key;
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
}
export {
@ -372,4 +344,4 @@ export {
ChartLegend,
ChartLegendContent,
ChartStyle,
};
}

144
components/ui/command.tsx Normal file
View File

@ -0,0 +1,144 @@
"use client"
import { type DialogProps, DialogTitle } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import * as React from "react"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogTitle />
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center bg-stone-100 px-3 dark:bg-stone-900" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("mb-1 max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:text-xs",
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-[8px] px-2 py-1.5 text-xs outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-stone-100 data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 dark:data-[selected='true']:bg-stone-900 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-muted-foreground text-xs tracking-widest", className)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

104
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,104 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("font-semibold text-lg leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -1,26 +1,26 @@
"use client";
"use client"
import { cn } from "@/lib/utils";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react"
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
@ -35,9 +35,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -46,14 +45,13 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -64,32 +62,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover p-1.5 text-popover-foreground shadow-2xl dark:shadow-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-2xl data-[state=closed]:animate-out data-[state=open]:animate-in dark:shadow-none",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-[10px] px-2 py-1.5 text-xs font-medium outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-default select-none items-center rounded-[10px] px-2 py-1.5 font-normal text-xs outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -98,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
checked={checked}
@ -111,9 +109,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -122,7 +119,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
{...props}
@ -134,26 +131,22 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
className={cn("px-2 py-1.5 font-semibold text-sm", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -164,21 +157,13 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
@ -196,4 +181,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
}

View File

@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import * as React from "react";
import { cn } from "@/lib/utils"
import * as React from "react"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
@ -9,15 +9,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
)
},
);
Input.displayName = "Input";
)
Input.displayName = "Input"
export { Input };
export { Input }

20
components/ui/label.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client"
import { cn } from "@/lib/utils"
import * as LabelPrimitive from "@radix-ui/react-label"
import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -1,8 +1,8 @@
import { cn } from "@/lib/utils";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import * as React from "react"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
@ -10,17 +10,14 @@ const NavigationMenu = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
@ -28,20 +25,17 @@ const NavigationMenuList = React.forwardRef<
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
);
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
@ -58,8 +52,8 @@ const NavigationMenuTrigger = React.forwardRef<
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
@ -68,33 +62,32 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<div className={cn("absolute top-full left-0 flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top-center overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
))
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
@ -103,16 +96,15 @@ const NavigationMenuIndicator = React.forwardRef<
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
"data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=visible]:animate-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
))
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName
export {
NavigationMenu,
@ -124,4 +116,4 @@ export {
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuViewport,
};
}

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