refactor: Optimize and improve the server-side admin panel.

This commit is contained in:
lxien
2026-08-16 17:06:04 +08:00
parent f9baf4ceab
commit 4fcd46e58c
54 changed files with 722 additions and 984 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "orbien-server-ui",
"version": "2.1.0-SNAPSHOT",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "orbien-server-ui",
"version": "2.1.0-SNAPSHOT",
"version": "3.0.0",
"dependencies": {
"vue": "^3.5.22",
"vue-i18n": "^11.4.8",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "orbien-server-ui",
"private": true,
"version": "2.1.0-SNAPSHOT",
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
+16 -16
View File
@@ -2,8 +2,8 @@ import type {
Page,
SystemInfo,
ClientInfo,
ProxyInfo,
ProxyTrafficResp,
TunnelInfo,
TunnelTrafficResp,
ApiResponse,
} from '@/types/api'
import {ApiError} from './errors'
@@ -31,34 +31,34 @@ export function fetchClients(page = 1, pageSize = 200) {
return api<Page<ClientInfo>>(`/api/v1/clients?page=${page}&pageSize=${pageSize}`)
}
export function fetchClient(runId: string) {
return api<ClientInfo>(`/api/v1/clients/${encodeURIComponent(runId)}`)
export function fetchClient(sessionId: string) {
return api<ClientInfo>(`/api/v1/clients/${encodeURIComponent(sessionId)}`)
}
export function kickClient(runId: string) {
return api<unknown>(`/api/v1/clients/${encodeURIComponent(runId)}/kick`, {
export function kickClient(sessionId: string) {
return api<unknown>(`/api/v1/clients/${encodeURIComponent(sessionId)}/kick`, {
method: 'POST',
})
}
export type ProxyListParams = {
export type TunnelListParams = {
page?: number
pageSize?: number
clientId?: string
sessionId?: string
q?: string
}
export function fetchProxies(pageOrParams: number | ProxyListParams = 1, pageSize = 200) {
const params: ProxyListParams =
export function fetchTunnels(pageOrParams: number | TunnelListParams = 1, pageSize = 200) {
const params: TunnelListParams =
typeof pageOrParams === 'number'
? {page: pageOrParams, pageSize}
: pageOrParams
const qs = new URLSearchParams()
qs.set('page', String(params.page ?? 1))
qs.set('pageSize', String(params.pageSize ?? 200))
if (params.clientId) qs.set('clientId', params.clientId)
if (params.sessionId) qs.set('sessionId', params.sessionId)
if (params.q) qs.set('q', params.q)
return api<Page<ProxyInfo>>(`/api/v1/proxies?${qs.toString()}`)
return api<Page<TunnelInfo>>(`/api/v1/tunnels?${qs.toString()}`)
}
export type TrafficRange = '7d' | '24h'
@@ -67,12 +67,12 @@ function trafficQuery(range: TrafficRange = '7d') {
return range === '24h' ? '?range=24h' : '?range=7d'
}
export function fetchProxyTraffic(name: string, range: TrafficRange = '7d') {
return api<ProxyTrafficResp>(
`/api/v1/proxies/${encodeURIComponent(name)}/traffic${trafficQuery(range)}`,
export function fetchTunnelTraffic(name: string, range: TrafficRange = '7d') {
return api<TunnelTrafficResp>(
`/api/v1/tunnels/${encodeURIComponent(name)}/traffic${trafficQuery(range)}`,
)
}
export function fetchSystemTraffic(range: TrafficRange = '7d') {
return api<ProxyTrafficResp>(`/api/v1/system/traffic${trafficQuery(range)}`)
return api<TunnelTrafficResp>(`/api/v1/system/traffic${trafficQuery(range)}`)
}
+3 -3
View File
@@ -4,8 +4,8 @@ export {
fetchClients,
fetchClient,
kickClient,
fetchProxies,
fetchProxyTraffic,
fetchTunnels,
fetchTunnelTraffic,
fetchSystemTraffic,
} from './client'
export type {ProxyListParams, TrafficRange} from './client'
export type {TunnelListParams, TrafficRange} from './client'
+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M512.2048 1024H512a25.4976 25.4976 0 0 1-18.1248-7.4752L168.0384 690.688a25.6 25.6 0 0 1 36.2496-36.2496l282.112 282.112V25.6a25.6 25.6 0 0 1 51.2 0v911.36l282.5216-282.5216a25.6 25.6 0 1 1 36.1984 36.2496l-325.8368 325.8368a25.4976 25.4976 0 0 1-18.2784 7.4752z"
/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M729.6 931.2l-416-425.6 416-416c9.6-9.6 9.6-25.6 0-35.2-9.6-9.6-25.6-9.6-35.2 0l-432 435.2c-9.6 9.6-9.6 25.6 0 35.2l432 441.6c9.6 9.6 25.6 9.6 35.2 0C739.2 956.8 739.2 940.8 729.6 931.2z"
/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M722.438 510.815c-0.246-0.246-0.513-0.454-0.766-0.69l-299.893-299.892c-14.231-14.24-37.291-14.24-51.529 0-14.241 14.231-14.241 37.291 0 51.529l274.851 274.859-274.849 274.851c-14.241 14.237-14.241 37.301 0 51.532 7.116 7.119 16.436 10.682 25.764 10.682 9.321 0 18.644-3.563 25.763-10.682l299.905-299.901c0.246-0.234 0.51-0.438 0.751-0.678 7.129-7.13 10.685-16.469 10.674-25.804 0.006-9.337-3.55-18.676-10.674-25.804z"
/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

+8
View File
@@ -0,0 +1,8 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1 0 0 -1 0 1024)">
<path
fill="currentColor"
d="M512.2048 1024H512a25.4976 25.4976 0 0 1-18.1248-7.4752L168.0384 690.688a25.6 25.6 0 0 1 36.2496-36.2496l282.112 282.112V25.6a25.6 25.6 0 0 1 51.2 0v911.36l282.5216-282.5216a25.6 25.6 0 1 1 36.1984 36.2496l-325.8368 325.8368a25.4976 25.4976 0 0 1-18.2784 7.4752z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 437 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M554.666667 682.666667v85.333333h128v42.666667H384v-42.666667h128v-85.333333H170.666667V213.333333h725.333333v469.333334h-341.333333z m0-42.666667h298.666666V256H213.333333v384h341.333334z"
/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M544.256 605.184l244.224-244.224a31.744 31.744 0 0 1 45.056 45.056l-295.424 295.424a36.864 36.864 0 0 1-51.2 0L190.464 406.528a31.744 31.744 0 1 1 45.056-45.056l244.224 244.224V111.104a32.256 32.256 0 1 1 64 0zM153.6 902.656a32.256 32.256 0 0 1 0-64h716.8a32.256 32.256 0 0 1 0 64z"
/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M184.552727 768l0-512c0-38.539636 31.278545-69.818182 69.818182-69.818182l302.545455 0L556.916364 139.636364l-325.818182 0c-51.432727 0-93.090909 41.658182-93.090909 93.090909l0 558.545455c0 51.432727 41.658182 93.090909 93.090909 93.090909l325.818182 0 0-46.545455-302.545455 0C215.784727 837.818182 184.552727 806.539636 184.552727 768zM924.113455 495.522909l-164.584727-164.584727c-9.076364-9.076364-23.831273-9.076364-32.907636 0-9.076364 9.076364-9.076364 23.831273 0 32.907636l124.834909 124.834909L394.007273 488.680727c-12.846545 0-23.272727 10.426182-23.272727 23.272727s10.426182 23.272727 23.272727 23.272727l457.448727 0-124.834909 124.834909c-9.076364 9.076364-9.076364 23.831273 0 32.907636 9.076364 9.076364 23.831273 9.076364 32.907636 0l164.584727-164.584727C933.189818 519.354182 933.189818 504.645818 924.113455 495.522909z"
/>
</svg>

After

Width:  |  Height:  |  Size: 960 B

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"
/>
<path
fill="currentColor"
d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M518.8 512.7c0-178.9 116.1-330.9 278.5-389.1-45.6-16.3-94.6-25.7-145.9-25.7C417 97.9 227 283.7 227 512.7c0 229.1 190 414.8 424.5 414.8 51.4 0 100.3-9.4 145.9-25.7-162.5-58.1-278.6-210.1-278.6-389.1z"
/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 1059 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M463.65500237 773.18500475a352.19666903 352.19666903 0 1 1 352.19666903-352.19666904 352.57094797 352.57094797 0 0 1-352.19666904 352.19666904z m0-641.95110188a289.81681265 289.81681265 0 1 0 289.81681265 289.81681266A290.12871176 290.12871176 0 0 0 463.65500237 131.23390287z"
/>
<path
fill="currentColor"
d="M952.6506972 912.47922422a31.18992819 31.18992819 0 0 1-21.77057017-8.85793963L679.61406566 657.96941048a31.18992819 31.18992819 0 0 1 43.66589914-44.60159729l251.26606138 245.65187411a31.18992819 31.18992819 0 0 1-21.83294917 53.52191674z"
/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M768.704 703.616c-35.648 0-67.904 14.72-91.136 38.304l-309.152-171.712c9.056-17.568 14.688-37.184 14.688-58.272 0-12.576-2.368-24.48-5.76-35.936l304.608-189.152c22.688 20.416 52.384 33.184 85.216 33.184 70.592 0 128-57.408 128-128s-57.408-128-128-128-128 57.408-128 128c0 14.56 2.976 28.352 7.456 41.408l-301.824 187.392c-23.136-22.784-54.784-36.928-89.728-36.928-70.592 0-128 57.408-128 128 0 70.592 57.408 128 128 128 25.664 0 49.504-7.744 69.568-20.8l321.216 178.4c-3.04 10.944-5.184 22.208-5.184 34.08 0 70.592 57.408 128 128 128s128-57.408 128-128S839.328 703.616 768.704 703.616zM767.2 128.032c35.296 0 64 28.704 64 64s-28.704 64-64 64-64-28.704-64-64S731.904 128.032 767.2 128.032zM191.136 511.936c0-35.296 28.704-64 64-64s64 28.704 64 64c0 35.296-28.704 64-64 64S191.136 547.232 191.136 511.936zM768.704 895.616c-35.296 0-64-28.704-64-64s28.704-64 64-64 64 28.704 64 64S804 895.616 768.704 895.616z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M192.437 816.222c-23.575 0-42.863-19.288-42.863-42.863v-64.294c0-23.575 19.288-42.863 42.863-42.863s42.863 19.288 42.863 42.863v64.294c0.005 23.57-19.284 42.863-42.863 42.863zM406.754 816.222c-23.575 0-42.862-19.288-42.862-42.863v-171.458c0-23.575 19.288-42.862 42.862-42.862s42.863 19.288 42.863 42.862v171.458c0.005 23.57-19.284 42.863-42.863 42.863zM621.080 816.222c-23.575 0-42.862-19.288-42.862-42.863v-342.912c0-23.575 19.288-42.862 42.862-42.862s42.862 19.288 42.862 42.862v342.908c0 23.575-19.288 42.867-42.862 42.867zM835.397 816.222c-23.575 0-42.863-19.288-42.863-42.863v-557.234c0-23.575 19.288-42.863 42.863-42.863s42.863 19.288 42.863 42.863v557.229c0 23.575-19.288 42.867-42.863 42.867z"
/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M510 192a32 32 0 0 1-32-32V80a32 32 0 1 1 64 0v80a32 32 0 0 1-32 32z m224.864 93.136a32 32 0 0 1 0-45.248l56.576-56.576a32 32 0 1 1 45.248 45.248l-56.56 56.56a32 32 0 0 1-45.264 0.016z m93.136 224.864a32 32 0 0 1 32-32h80a32 32 0 1 1 0 64h-80a32 32 0 0 1-32-32z m-93.136 224.864a32 32 0 0 1 45.248 0l56.56 56.56a32 32 0 1 1-45.248 45.248l-56.576-56.56a32 32 0 0 1 0.016-45.248zM510 828a32 32 0 0 1 32 32v80a32 32 0 1 1-64 0v-80a32 32 0 0 1 32-32z m-224.864-93.136a32 32 0 0 1 0 45.248l-56.56 56.56a32 32 0 0 1-45.248-45.248l56.576-56.56a32 32 0 0 1 45.232 0zM192 510a32 32 0 0 1-32 32H80a32 32 0 1 1 0-64h80a32 32 0 0 1 32 32z m93.136-224.864a32 32 0 0 1-45.248 0l-56.576-56.56a32 32 0 0 1 45.248-45.248l56.56 56.576a32 32 0 0 1 0.016 45.232zM510 272c132.544 0 240 107.456 240 240 0 132.544-107.456 240-240 240-132.544 0-240-107.456-240-240 0-132.544 107.456-240 240-240z m0 64c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M754.460444 568.32l-202.524444-190.008889 2.56 418.929778c0 31.516444-11.491556 56.092444-42.609778 56.092444-31.061333 0-42.723556-24.576-42.723555-56.092444V378.311111l-223.516445 215.779556c-22.016 22.300444-43.349333 24.462222-61.098666 7.623111-18.716444-18.375111-19.342222-40.448 2.673777-62.805334l284.672-284.899555a54.840889 54.840889 0 0 1 18.488889-12.401778l0.853334-0.227555a55.011556 55.011556 0 0 1 41.130666 0l0.853334 0.227555c7.054222 2.958222 13.198222 7.111111 18.545777 12.401778l285.013334 284.899555c22.072889 22.357333 10.069333 42.894222-3.640889 58.424889-19.000889 17.976889-56.661333-6.656-78.677334-29.013333z m42.325334-397.198222H226.986667c-31.061333 0-56.376889-19.171556-56.376889-42.666667 0-23.552 25.315556-42.666667 56.376889-42.666667h569.742222c31.061333 0 56.32 19.114667 56.32 42.666667 0 23.495111-25.144889 42.666667-56.32 42.666667z"
/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M506.075809 546.976206c-145.260076 0-263.436846-118.16774-263.436846-263.418785 0-145.260076 118.17677-263.436846 263.436846-263.436846 145.260076 0 263.436846 118.16774 263.436846 263.436846C769.512655 428.799436 651.335885 546.976206 506.075809 546.976206zM506.075809 76.996419c-113.896181 0-206.561002 92.664821-206.561002 206.561002S392.179628 490.100362 506.075809 490.100362c113.905212 0 206.561002-92.646759 206.561002-206.54294S619.981021 76.996419 506.075809 76.996419z"
/>
<path
fill="currentColor"
d="M514.754388 621.191146c-250.902125 0-455.024817 174.88103-455.024817 389.840656l28.437922 0c0-199.607302 190.991939-361.411765 426.586895-361.411765s426.586895 161.804462 426.586895 361.411765l20.156698 0 8.281224 0C969.788235 796.072176 765.647482 621.191146 514.754388 621.191146z"
/>
<path
fill="currentColor"
d="M514.754388 678.057959c219.547262 0 398.148973 149.360049 398.148973 332.964812l28.437922 0c0-199.607302-190.991939-361.411765-426.586895-361.411765S88.167493 811.4245 88.167493 1011.031802l28.437922 0C116.605415 827.427039 295.207126 678.057959 514.754388 678.057959z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M865.229155 571.255262c33.704828-39.396915 52.443945-90.753607 52.699769-143.965024 0-113.841734-79.049653-204.723253-177.797764-204.723253-19.762413 0-39.524827 22.704391-39.524827 45.472738s19.826369 45.536694 39.524827 45.536693c52.699769 0 98.876023 53.083505 98.876023 113.841734 0 60.566361-46.176254 113.841734-98.876023 113.841734-19.762413 0-39.524827 15.093623-39.524827 45.472737s19.826369 45.536694 39.524827 45.536694c111.923053 0 204.147648 106.16701 204.147648 235.038411 0 22.768347 19.762413 45.536694 39.524827 45.536693s39.524827-22.768347 39.524827-45.536693c6.395603-129.191181-59.479108-243.032915-158.099307-296.052464z"
/>
<path
fill="currentColor"
d="M701.181937 346.897508c0 96.701518-47.327462 185.28062-125.993379 241.625882a418.272438 418.272438 0 0 1 259.981263 386.550247c0 24.047467-23.791643 48.286803-47.327463 48.286803-23.727687 0-47.263506-24.111423-47.263506-48.286803a321.315096 321.315096 0 0 0-322.913997-322.21048 321.187184 321.187184 0 0 0-322.786084 322.21048c0 24.047467-23.663731 48.286803-47.263506 48.286803C24.015489 1023.296484 0.351758 999.249016 0.351758 975.073637c0-169.1637 102.329648-322.082568 259.853351-386.550247-78.729873-56.345263-125.99338-144.924364-125.99338-241.625882 0-161.169196 125.99338-289.976641 283.453126-289.976641 157.459746 0 283.517082 128.807445 283.517082 289.976641z m-94.335144 0.063956c0-104.759978-86.660421-193.339079-189.054026-193.339079-102.265692 0-189.117981 88.579102-189.117981 193.339079 0 104.823934 78.793829 193.339079 189.117981 193.339079 110.324152 0 189.054025-88.515146 189.054026-193.339079z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+26 -106
View File
@@ -23,7 +23,6 @@ html[data-theme='dark'] {
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
--status-ok: #52c41a;
--status-ok-soft: rgba(82, 196, 26, 0.14);
--table-head-bg: rgba(255, 255, 255, 0.04);
--scrollbar: #434343;
--header-height: 56px;
--sidebar-width: 200px;
@@ -55,7 +54,6 @@ html[data-theme='light'] {
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
--status-ok: #52c41a;
--status-ok-soft: rgba(82, 196, 26, 0.1);
--table-head-bg: #fafafa;
--scrollbar: #d9d9d9;
}
@@ -82,11 +80,8 @@ body {
.top,
.sidebar,
.card,
table,
.ghost,
.theme-toggle,
.icon-btn,
.pill,
.err,
.side-link,
.sidebar-collapse {
@@ -147,6 +142,9 @@ table,
align-items: center;
gap: 0.65rem;
min-width: 0;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.logo-img {
@@ -186,7 +184,6 @@ html[data-theme='dark'] .brand-orb {
flex-shrink: 0;
}
.ghost,
.theme-toggle,
.icon-btn {
background: var(--panel);
@@ -217,14 +214,12 @@ html[data-theme='dark'] .brand-orb {
stroke-linejoin: round;
}
.ghost:hover:not(:disabled),
.theme-toggle:hover,
.icon-btn:hover {
border-color: var(--line-strong);
background: var(--panel-hover);
}
.ghost:focus-visible,
.theme-toggle:focus-visible,
.icon-btn:focus-visible,
.side-link:focus-visible,
@@ -234,11 +229,6 @@ html[data-theme='dark'] .brand-orb {
box-shadow: var(--focus-ring);
}
.ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.theme-toggle {
display: inline-grid;
place-items: center;
@@ -251,11 +241,8 @@ html[data-theme='dark'] .brand-orb {
.theme-toggle svg {
width: 1.1rem;
height: 1.1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
fill: currentColor;
stroke: none;
}
.locale-switch {
@@ -355,11 +342,8 @@ html[data-theme='dark'] .brand-orb {
.side-icon svg {
width: 1.2rem;
height: 1.2rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
fill: currentColor;
stroke: none;
}
.side-label {
@@ -395,14 +379,26 @@ html[data-theme='dark'] .brand-orb {
}
.sidebar-collapse svg {
width: 1.1rem;
height: 1.1rem;
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
fill: currentColor;
stroke: none;
}
.collapse-icon {
width: 1.05rem;
height: 1.05rem;
display: inline-grid;
place-items: center;
line-height: 0;
color: inherit;
}
.collapse-icon svg {
width: 100%;
height: 100%;
display: block;
}
.sidebar-backdrop {
@@ -454,10 +450,6 @@ html[data-theme='dark'] .brand-orb {
border-color: var(--line-strong);
}
.card.wide {
grid-column: 1 / -1;
}
.k {
color: var(--muted);
font-size: 0.85rem;
@@ -477,78 +469,6 @@ html[data-theme='dark'] .brand-orb {
color: var(--text-secondary);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow);
}
thead {
background: var(--table-head-bg);
}
th,
td {
text-align: left;
padding: 0.7rem 0.85rem;
border-bottom: 1px solid var(--line);
color: var(--text);
}
th {
color: var(--muted);
font-weight: 500;
font-size: 0.85rem;
}
tbody tr:hover td {
background: var(--panel-hover);
}
tbody tr:last-child td {
border-bottom: none;
}
.empty {
color: var(--muted);
text-align: center;
}
.pill {
display: inline-block;
background: var(--accent-soft);
color: var(--accent-text);
border: 1px solid transparent;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--status-ok);
background: var(--status-ok-soft);
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.status::before {
content: '';
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
}
.err {
color: var(--danger);
background: var(--danger-bg);
+33 -95
View File
@@ -1,112 +1,50 @@
<script lang="ts">
export type AppIconName =
| 'globe'
| 'server'
| 'monitor'
| 'link'
| 'bolt'
| 'signal'
| 'shield'
| 'mux'
| 'users'
| 'user'
| 'proxies'
| 'tag'
| 'waves'
| 'arrow-down'
| 'arrow-up'
| 'kick'
export type AppIconName = 'monitor' | 'link' | 'users' | 'user' | 'tunnels' | 'kick'
</script>
<script setup lang="ts">
defineProps<{
import {computed} from 'vue'
import computerIcon from '@/assets/icon/computer.svg?raw'
import linkIcon from '@/assets/icon/link.svg?raw'
import userIcon from '@/assets/icon/user.svg?raw'
import usersIcon from '@/assets/icon/users.svg?raw'
import shareIcon from '@/assets/icon/share.svg?raw'
import kickIcon from '@/assets/icon/kick.svg?raw'
const props = defineProps<{
name: AppIconName
}>()
const ASSET_ICONS: Record<AppIconName, string> = {
monitor: computerIcon,
link: linkIcon,
user: userIcon,
users: usersIcon,
tunnels: shareIcon,
kick: kickIcon,
}
const assetSvg = computed(() => ASSET_ICONS[props.name])
</script>
<template>
<svg viewBox="0 0 24 24" aria-hidden="true">
<template v-if="name === 'globe'">
<circle cx="12" cy="12" r="9"/>
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>
</template>
<template v-else-if="name === 'server'">
<rect x="4" y="4" width="16" height="6" rx="1.5"/>
<rect x="4" y="14" width="16" height="6" rx="1.5"/>
<path d="M8 7h.01M8 17h.01"/>
</template>
<template v-else-if="name === 'monitor'">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8M12 16v4"/>
</template>
<template v-else-if="name === 'link'">
<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.2 1.2"/>
<path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.2-1.2"/>
</template>
<template v-else-if="name === 'bolt'">
<path d="M13 3 5 13h6l-1 8 9-12h-6l1-6z"/>
</template>
<template v-else-if="name === 'signal'">
<path d="M5 18v-2M9 18v-5M13 18V8M17 18V5"/>
</template>
<template v-else-if="name === 'shield'">
<path d="M12 3 5 6v5c0 5 3.2 8.4 7 9.5 3.8-1.1 7-4.5 7-9.5V6l-7-3z"/>
</template>
<template v-else-if="name === 'users'">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="3.2"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</template>
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="3.2"/>
<path d="M5 19c1.8-3.2 4.2-4.8 7-4.8s5.2 1.6 7 4.8"/>
</template>
<template v-else-if="name === 'proxies'">
<circle cx="6" cy="12" r="2.5"/>
<circle cx="18" cy="7" r="2.5"/>
<circle cx="18" cy="17" r="2.5"/>
<path d="M8.5 11.2 15.5 8.2M8.5 12.8l7 3.8"/>
</template>
<template v-else-if="name === 'tag'">
<path d="M20 12 12 4H5a1 1 0 0 0-1 1v7l8 8 8-8z"/>
<circle cx="8.5" cy="8.5" r="1.2"/>
</template>
<template v-else-if="name === 'waves'">
<path d="M2 8c2.5 2.5 5.5 2.5 8 0s5.5-2.5 8 0 5.5 2.5 8 0"/>
<path d="M2 14c2.5 2.5 5.5 2.5 8 0s5.5-2.5 8 0 5.5 2.5 8 0"/>
<path d="M2 20c2.5 2.5 5.5 2.5 8 0s5.5-2.5 8 0 5.5 2.5 8 0"/>
</template>
<template v-else-if="name === 'arrow-down'">
<path d="M12 3v12"/>
<path d="m7 10 5 5 5-5"/>
<path d="M5 19h14"/>
</template>
<template v-else-if="name === 'arrow-up'">
<path d="M12 21V9"/>
<path d="m7 14 5-5 5 5"/>
<path d="M5 5h14"/>
</template>
<template v-else-if="name === 'kick'">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<path d="M16 17l5-5-5-5"/>
<path d="M21 12H9"/>
</template>
<template v-else>
<rect x="4" y="4" width="16" height="16" rx="3"/>
<path d="M8 12h8M12 8v8"/>
</template>
</svg>
<span class="app-icon-asset" aria-hidden="true" v-html="assetSvg"/>
</template>
<style scoped>
svg {
.app-icon-asset {
width: 1em;
height: 1em;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
display: block;
line-height: 0;
color: inherit;
}
.app-icon-asset :deep(svg) {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
stroke: none;
}
</style>
+1 -1
View File
@@ -66,7 +66,7 @@ function donutArc(x: number, y: number, radius: number, start: number, end: numb
class="donut-svg"
:viewBox="`0 0 ${size} ${size}`"
role="img"
:aria-label="t('monitor.proxyTypes')"
:aria-label="t('monitor.tunnelTypes')"
>
<circle
v-if="!arcs.length"
+1 -16
View File
@@ -1,5 +1,5 @@
<script lang="ts">
export type IconTone = 'blue' | 'green' | 'purple' | 'violet' | 'orange' | 'slate' | 'teal'
export type IconTone = 'blue' | 'green' | 'violet' | 'orange'
</script>
<script setup lang="ts">
@@ -43,11 +43,6 @@ withDefaults(
--icon-soft: rgba(34, 197, 94, 0.14);
}
.tone-purple {
--icon-fg: #8b5cf6;
--icon-soft: rgba(139, 92, 246, 0.14);
}
.tone-violet {
--icon-fg: #6366f1;
--icon-soft: rgba(99, 102, 241, 0.14);
@@ -57,14 +52,4 @@ withDefaults(
--icon-fg: #f97316;
--icon-soft: rgba(249, 115, 22, 0.14);
}
.tone-slate {
--icon-fg: #64748b;
--icon-soft: rgba(100, 116, 139, 0.16);
}
.tone-teal {
--icon-fg: #14b8a6;
--icon-soft: rgba(20, 184, 166, 0.14);
}
</style>
+20 -14
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import {computed} from 'vue'
import {useLocale} from '@/composables/useLocale'
import arrowLeftIcon from '@/assets/icon/arrow-left.svg?raw'
import arrowRightIcon from '@/assets/icon/arrow-right.svg?raw'
const props = withDefaults(
defineProps<{
@@ -76,9 +78,7 @@ function onPageSizeChange(evt: Event) {
:aria-label="t('common.prevPage')"
@click="go(current - 1)"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M10 3.5 5.5 8 10 12.5"/>
</svg>
<span class="nav-icon" aria-hidden="true" v-html="arrowLeftIcon"/>
</button>
<button
@@ -99,9 +99,7 @@ function onPageSizeChange(evt: Event) {
:aria-label="t('common.nextPage')"
@click="go(current + 1)"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 3.5 10.5 8 6 12.5"/>
</svg>
<span class="nav-icon" aria-hidden="true" v-html="arrowRightIcon"/>
</button>
</div>
</div>
@@ -180,14 +178,22 @@ function onPageSizeChange(evt: Event) {
place-items: center;
}
.nav-btn svg {
width: 0.95rem;
height: 0.95rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
.nav-btn svg,
.nav-icon {
width: 0.9rem;
height: 0.9rem;
display: inline-grid;
place-items: center;
line-height: 0;
color: inherit;
}
.nav-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
stroke: none;
}
.nav-btn:hover:not(:disabled),
+3 -17
View File
@@ -1,11 +1,7 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
description?: string
}>(),
{description: ''},
)
defineProps<{
title: string
}>()
</script>
<template>
@@ -13,9 +9,6 @@ withDefaults(
<header class="section-head">
<div>
<h2 class="section-title">{{ title }}</h2>
<p v-if="description || $slots.description" class="section-desc">
<slot name="description">{{ description }}</slot>
</p>
</div>
<div v-if="$slots.extra" class="section-extra">
<slot name="extra"/>
@@ -50,13 +43,6 @@ withDefaults(
letter-spacing: 0.01em;
}
.section-desc {
margin: 0.3rem 0 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.4;
}
.section-extra {
flex-shrink: 0;
}
+1 -11
View File
@@ -5,11 +5,10 @@ import type {AppIconName} from '@/components/AppIcon.vue'
withDefaults(
defineProps<{
label: string
hint?: string
icon?: AppIconName
tone?: IconTone
}>(),
{hint: '', tone: 'blue'},
{tone: 'blue'},
)
</script>
@@ -24,9 +23,6 @@ withDefaults(
</div>
<IconBadge v-if="icon" :name="icon" :tone="tone" class="stat-icon"/>
</div>
<div v-if="hint || $slots.hint" class="stat-hint">
<slot name="hint">{{ hint }}</slot>
</div>
</div>
</template>
@@ -61,10 +57,4 @@ withDefaults(
.stat-icon {
margin-top: 0.05rem;
}
.stat-hint {
margin-top: 0.45rem;
color: var(--muted);
font-size: 0.78rem;
}
</style>
+21 -9
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import {useTheme} from '@/composables/useTheme'
import sunIcon from '@/assets/icon/sun.svg?raw'
import moonIcon from '@/assets/icon/moon.svg?raw'
const {isDark, label, toggle} = useTheme()
</script>
@@ -12,14 +14,24 @@ const {isDark, label, toggle} = useTheme()
:title="label"
@click="toggle"
>
<svg v-if="isDark" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"/>
<path
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
/>
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3 7 7 0 1 0 21 14.5z"/>
</svg>
<span class="theme-icon" aria-hidden="true" v-html="isDark ? sunIcon : moonIcon"/>
</button>
</template>
<style scoped>
.theme-icon {
display: inline-grid;
place-items: center;
width: 1.1rem;
height: 1.1rem;
line-height: 0;
}
.theme-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
stroke: none;
}
</style>
+6 -6
View File
@@ -1,18 +1,18 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {fetchProxyTraffic, fetchSystemTraffic, type TrafficRange} from '@/api/client'
import {fetchTunnelTraffic, fetchSystemTraffic, type TrafficRange} from '@/api/client'
import {formatFileSize} from '@/utils/format'
import {useLocale} from '@/composables/useLocale'
const props = withDefaults(
defineProps<{
proxyName?: string
tunnelName?: string
range?: TrafficRange
variant?: 'bar' | 'line'
refreshMs?: number
}>(),
{
proxyName: '',
tunnelName: '',
range: '7d',
variant: 'bar',
refreshMs: 0,
@@ -166,12 +166,12 @@ const hoverX = computed(() => {
async function load() {
const seq = ++loadSeq
const range = props.range
const name = props.proxyName
const name = props.tunnelName
loading.value = true
error.value = null
try {
const data = name
? await fetchProxyTraffic(name, range)
? await fetchTunnelTraffic(name, range)
: await fetchSystemTraffic(range)
if (seq !== loadSeq) return
granularity.value = data.granularity || (range === '24h' ? 'hour' : 'day')
@@ -238,7 +238,7 @@ onUnmounted(() => {
})
watch(
() => [props.proxyName, props.range] as const,
() => [props.tunnelName, props.range] as const,
() => {
hoverIndex.value = null
points.value = []
+16 -11
View File
@@ -2,6 +2,8 @@
import {computed} from 'vue'
import {formatFileSize} from '@/utils/format'
import {useLocale} from '@/composables/useLocale'
import arrowUpIcon from '@/assets/icon/arrow-up.svg?raw'
import arrowDownIcon from '@/assets/icon/arrow-down.svg?raw'
const props = withDefaults(
defineProps<{
@@ -31,16 +33,12 @@ const outbound = computed(() => Number(props.trafficOut ?? 0) || 0)
:title="`${t('traffic.out')}: ${formatFileSize(outbound)} · ${t('traffic.in')}: ${formatFileSize(inbound)}`"
>
<div class="row out">
<svg class="arrow" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 12.5V3.5M4.5 7 8 3.5 11.5 7"/>
</svg>
<span class="arrow" aria-hidden="true" v-html="arrowUpIcon"/>
<span class="val">{{ formatFileSize(outbound) }}</span>
</div>
<span v-if="layout === 'inline'" class="sep" aria-hidden="true">/</span>
<div class="row in">
<svg class="arrow" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.5v9M4.5 9 8 12.5 11.5 9"/>
</svg>
<span class="arrow" aria-hidden="true" v-html="arrowDownIcon"/>
<span class="val">{{ formatFileSize(inbound) }}</span>
</div>
</div>
@@ -101,11 +99,18 @@ const outbound = computed(() => Number(props.trafficOut ?? 0) || 0)
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
display: inline-grid;
place-items: center;
line-height: 0;
color: inherit;
}
.arrow :deep(svg) {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
stroke: none;
}
.row.out {
+14 -22
View File
@@ -2,6 +2,8 @@
import {computed} from 'vue'
import {formatFileSize} from '@/utils/format'
import {useLocale} from '@/composables/useLocale'
import downloadIcon from '@/assets/icon/download.svg?raw'
import uploadIcon from '@/assets/icon/upload.svg?raw'
const props = withDefaults(
defineProps<{
@@ -36,13 +38,7 @@ const outShare = computed(() => 100 - inShare.value)
<div class="traffic-split">
<div class="traffic-item in">
<div class="traffic-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 3v12"/>
<path d="m7 10 5 5 5-5"/>
<path d="M5 19h14"/>
</svg>
</div>
<div class="traffic-icon is-asset" aria-hidden="true" v-html="downloadIcon"/>
<div class="traffic-meta">
<div class="traffic-label">{{ t('traffic.in') }}</div>
<div class="traffic-value">{{ formatFileSize(inbound) }}</div>
@@ -52,13 +48,7 @@ const outShare = computed(() => 100 - inShare.value)
<div class="traffic-divider" aria-hidden="true"/>
<div class="traffic-item out">
<div class="traffic-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 21V9"/>
<path d="m7 14 5-5 5 5"/>
<path d="M5 5h14"/>
</svg>
</div>
<div class="traffic-icon is-asset" aria-hidden="true" v-html="uploadIcon"/>
<div class="traffic-meta">
<div class="traffic-label">{{ t('traffic.out') }}</div>
<div class="traffic-value">{{ formatFileSize(outbound) }}</div>
@@ -128,14 +118,16 @@ const outShare = computed(() => 100 - inShare.value)
flex-shrink: 0;
}
.traffic-icon svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
.traffic-icon.is-asset {
line-height: 0;
}
.traffic-icon.is-asset :deep(svg) {
width: 1.15rem;
height: 1.15rem;
display: block;
fill: currentColor;
stroke: none;
}
.traffic-item.in .traffic-icon {
+2 -8
View File
@@ -9,7 +9,7 @@ import {
} from '@/i18n'
export function useLocale() {
const {locale, t} = useI18n<{ message: MessageSchema }, AppLocale>()
const {locale, t} = useI18n<{message: MessageSchema}, AppLocale>()
const current = computed(() => locale.value as AppLocale)
const options = SUPPORTED_LOCALES.map((code) => ({
@@ -21,11 +21,5 @@ export function useLocale() {
setLocale(next)
}
function cycleLocale() {
const list = SUPPORTED_LOCALES
const idx = list.indexOf(current.value)
switchLocale(list[(idx + 1) % list.length]!)
}
return {t, current, options, switchLocale, cycleLocale}
return {t, current, options, switchLocale}
}
+31
View File
@@ -0,0 +1,31 @@
import {useLocale} from '@/composables/useLocale'
export function usePresence() {
const {t} = useLocale()
function isOnline(raw?: string) {
return !raw || raw === 'online'
}
function statusLabel(raw?: string) {
if (isOnline(raw)) return t('status.online')
if (raw === 'offline') return t('status.offline')
return raw || t('status.offline')
}
function formatSeen(secs: number, online: boolean) {
const n = Math.max(0, Math.floor(secs || 0))
if (online) {
if (n < 60) return t('clients.uptimeSecs', {n})
if (n < 3600) return t('clients.uptimeMins', {n: Math.floor(n / 60)})
if (n < 86400) return t('clients.uptimeHours', {n: Math.floor(n / 3600)})
return t('clients.uptimeDays', {n: Math.floor(n / 86400)})
}
if (n < 60) return t('clients.agoSecs', {n})
if (n < 3600) return t('clients.agoMins', {n: Math.floor(n / 60)})
if (n < 86400) return t('clients.agoHours', {n: Math.floor(n / 3600)})
return t('clients.agoDays', {n: Math.floor(n / 86400)})
}
return {isOnline, statusLabel, formatSeen}
}
-5
View File
@@ -42,10 +42,6 @@ export function useSidebar() {
mobileOpen.value = false
}
function openMobile() {
mobileOpen.value = true
}
watch([mobileOpen, isMobile], ([open, mobile]) => {
document.body.style.overflow = open && mobile ? 'hidden' : ''
})
@@ -57,6 +53,5 @@ export function useSidebar() {
desktopCollapsed,
toggleCollapsed,
closeMobile,
openMobile,
}
}
+15 -23
View File
@@ -1,33 +1,25 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { applyTheme, resolveTheme, toggleTheme, type ThemeMode } from '@/utils/theme'
import type { AppLocale, MessageSchema } from '@/i18n'
import {computed, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {resolveTheme, toggleTheme, type ThemeMode} from '@/utils/theme'
import type {AppLocale, MessageSchema} from '@/i18n'
function readDomTheme(): ThemeMode {
const d = document.documentElement.dataset.theme
return d === 'light' || d === 'dark' ? d : resolveTheme()
const d = document.documentElement.dataset.theme
return d === 'light' || d === 'dark' ? d : resolveTheme()
}
const mode = ref<ThemeMode>(readDomTheme())
export function useTheme() {
const { t } = useI18n<{ message: MessageSchema }, AppLocale>()
const isDark = computed(() => mode.value === 'dark')
const label = computed(() =>
mode.value === 'dark' ? t('actions.themeToLight') : t('actions.themeToDark'),
)
const hint = computed(() =>
mode.value === 'dark' ? t('actions.themeLightHint') : t('actions.themeDarkHint'),
)
const {t} = useI18n<{message: MessageSchema}, AppLocale>()
const isDark = computed(() => mode.value === 'dark')
const label = computed(() =>
mode.value === 'dark' ? t('actions.themeToLight') : t('actions.themeToDark'),
)
function setTheme(next: ThemeMode) {
applyTheme(next)
mode.value = next
}
function toggle() {
mode.value = toggleTheme(mode.value)
}
function toggle() {
mode.value = toggleTheme(mode.value)
}
return { mode, isDark, label, hint, setTheme, toggle }
return {mode, isDark, label, toggle}
}
+4 -4
View File
@@ -1,12 +1,12 @@
export interface NavItem {
name: 'monitor' | 'proxies' | 'clients'
name: 'monitor' | 'tunnels' | 'clients'
path: string
labelKey: 'monitor' | 'proxies' | 'clients'
icon: 'monitor' | 'proxies' | 'clients'
labelKey: 'monitor' | 'tunnels' | 'clients'
icon: 'monitor' | 'tunnels' | 'clients'
}
export const NAV_ITEMS: readonly NavItem[] = [
{name: 'monitor', path: '/', labelKey: 'monitor', icon: 'monitor'},
{name: 'proxies', path: '/proxies', labelKey: 'proxies', icon: 'proxies'},
{name: 'tunnels', path: '/tunnels', labelKey: 'tunnels', icon: 'tunnels'},
{name: 'clients', path: '/clients', labelKey: 'clients', icon: 'clients'},
]
+22 -74
View File
@@ -1,23 +1,15 @@
import type {MessageSchema} from '../../schema'
const enUS: MessageSchema = {
app: {
brand: 'orbi',
brandAccent: 'ens',
title: 'Dashboard',
},
nav: {
menu: 'Main navigation',
monitor: 'Monitor',
proxies: 'Proxies',
tunnels: 'Tunnels',
clients: 'Clients',
},
actions: {
refresh: 'Refresh',
themeToLight: 'Switch to light theme',
themeToDark: 'Switch to dark theme',
themeLightHint: 'Light',
themeDarkHint: 'Dark',
locale: 'Language',
github: 'GitHub repository',
collapseSidebar: 'Collapse menu',
@@ -36,63 +28,32 @@ const enUS: MessageSchema = {
nextPage: 'Next page',
},
overview: {
version: 'Version',
clients: 'Clients',
totalClients: 'Total clients',
onlineClients: 'Online clients',
proxies: 'Proxies',
tunnels: 'Tunnels',
connections: 'Connections',
bindPort: 'Bind Port',
server: 'Server',
emptyConfig: 'Not configured',
},
monitor: {
listen: 'Listen & transport',
listenDesc: 'Control-plane listeners and transport options',
vhost: 'Virtual host',
vhostDesc: 'Public HTTP / HTTPS entry; port 0 means disabled',
proxyTypes: 'Proxy types',
proxyTypesDesc: 'Online proxies grouped by type',
proxyDist: 'Proxy distribution',
proxyDistDesc: 'Share of HTTP, HTTPS, TCP, UDP, and more',
tunnelTypes: 'Tunnel types',
tunnelDist: 'Tunnel distribution',
serverConfig: 'Server configuration',
chartTotal: 'Total',
metricAddr: 'Server address',
metricTcp: 'TCP transport',
metricWebsocket: 'WebSocket transport',
metricQuic: 'QUIC transport',
metricKcp: 'KCP transport',
metricHttp: 'HTTP proxy',
metricHttps: 'HTTPS proxy',
metricTcpMux: 'TCP multiplexing',
metricTlsForce: 'Force TLS',
metricSubDomain: 'Root domain',
metricMaxPool: 'Max pool count',
metricHeartbeat: 'Heartbeat timeout',
bindAddr: 'Bind address',
bindPort: 'TCP port',
quicBindPort: 'QUIC port',
kcpBindPort: 'KCP port',
quicPort: 'QUIC port',
kcpPort: 'KCP port',
tcpMux: 'TCP multiplexing',
tlsForce: 'Force TLS',
vhostHTTPPort: 'HTTP port',
vhostHTTPSPort: 'HTTPS port',
subDomainHost: 'Root domain',
tipPortZero: 'Port 0 means not configured',
hintClients: 'Online control connections',
hintProxies: 'Total proxies registered by clients',
hintConns: 'Active visitor / proxy connections',
hintBind: 'Primary control / work port',
hintMuxOn: 'yamux on',
hintMuxOff: 'yamux off',
httpGwPort: 'HTTP gateway port',
httpsGwPort: 'HTTPS gateway port',
rootDomain: 'Root domain',
maxConnPool: 'Max connection pool',
heartbeatTimeout: 'Heartbeat timeout',
version: 'Version',
},
clients: {
runId: 'Run ID',
user: 'User',
hostname: 'Hostname',
ip: 'IP',
os: 'OS',
arch: 'Arch',
osFamily: {
windows: 'Windows',
macos: 'macOS',
@@ -101,13 +62,10 @@ const enUS: MessageSchema = {
freebsd: 'FreeBSD',
other: 'Unknown',
},
version: 'Version',
status: 'Status',
proxies: 'Proxies',
tunnels: 'Tunnels',
connected: 'Connected',
disconnected: 'Disconnected',
connections: 'Connections',
connectedSecs: '{n}s',
empty: 'No clients',
filter: 'Filter by status',
filterAll: 'All',
@@ -127,28 +85,22 @@ const enUS: MessageSchema = {
detail: 'Client details',
notFound: 'Client not found',
notFoundDesc: 'This client may have been removed, or has never connected.',
searchProxies: 'Search proxies…',
proxiesEmpty: 'No proxies for this client',
proxiesSearchEmpty: 'No proxies match "{q}"',
searchTunnels: 'Search tunnels…',
tunnelsEmpty: 'No tunnels for this client',
tunnelsSearchEmpty: 'No tunnels match "{q}"',
},
proxies: {
name: 'Name',
type: 'Type',
tunnels: {
port: 'Port',
domain: 'Domain',
localAddr: 'Local',
client: 'Client',
status: 'Status',
empty: 'No proxies',
empty: 'No tunnels',
traffic: 'Traffic',
trafficIn: 'Traffic In',
trafficOut: 'Traffic Out',
curConns: 'Conns',
total: 'Total {n}',
activeConns: 'Conns',
filter: 'Filter by protocol',
filterAll: 'All',
filterEmpty: 'No proxies for this protocol',
back: 'Back to proxies',
filterEmpty: 'No tunnels for this protocol',
back: 'Back to tunnels',
lastStarted: 'Last Started {time}',
openClient: 'Open client details',
},
@@ -158,12 +110,8 @@ const enUS: MessageSchema = {
total: 'Traffic today',
today: 'Today',
network: 'Network Traffic',
networkDesc: 'Inbound = visitor → tunnel; Outbound = tunnel → visitor',
history: 'Traffic Statistics',
historyDesc: 'Daily totals. In = visitor → tunnel, Out = tunnel → visitor',
historyAll: 'Traffic trend',
historyAllDesc: 'Inbound / outbound totals across all proxies for the last 7 calendar days',
historyAllDesc24h: 'Rolling last 24 hours (hourly buckets) across all proxies',
range: 'Time range',
range24h: '24h',
range7d: '7d',
@@ -179,7 +127,7 @@ const enUS: MessageSchema = {
offline: 'Offline',
},
errors: {
unauthorized: 'Unauthorized — check webServer.user / password',
unauthorized: 'Unauthorized — check dashboard.user / password',
http: '{status} {statusText}',
api: 'API error',
unknown: 'Unknown error',
+22 -74
View File
@@ -1,23 +1,15 @@
import type {MessageSchema} from '../../schema'
const zhCN: MessageSchema = {
app: {
brand: 'orbi',
brandAccent: 'ens',
title: '管理面板',
},
nav: {
menu: '主导航',
monitor: '监控',
proxies: '代理',
tunnels: '隧道',
clients: '客户端',
},
actions: {
refresh: '刷新',
themeToLight: '切换到白天主题',
themeToDark: '切换到夜晚主题',
themeLightHint: '白天',
themeDarkHint: '夜晚',
locale: '语言',
github: 'GitHub 仓库',
collapseSidebar: '收起菜单',
@@ -36,63 +28,32 @@ const zhCN: MessageSchema = {
nextPage: '下一页',
},
overview: {
version: '版本',
clients: '客户端',
totalClients: '总客户端',
onlineClients: '在线客户端',
proxies: '代理',
tunnels: '隧道',
connections: '连接数',
bindPort: '绑定端口',
server: '服务端',
emptyConfig: '未配置',
},
monitor: {
listen: '监听与传输',
listenDesc: '控制面监听与传输层选项',
vhost: '虚拟主机',
vhostDesc: 'HTTP / HTTPS 对外入口;端口为 0 表示未启用',
proxyTypes: '代理类型分布',
proxyTypesDesc: '当前在线代理按类型汇总',
proxyDist: '代理分布',
proxyDistDesc: 'HTTP、HTTPS、TCP、UDP 等类型占比',
tunnelTypes: '隧道类型分布',
tunnelDist: '隧道分布',
serverConfig: '服务器配置',
chartTotal: '合计',
metricAddr: '服务器地址',
metricTcp: 'TCP 传输',
metricWebsocket: 'WebSocket 传输',
metricQuic: 'QUIC 传输',
metricKcp: 'KCP 传输',
metricHttp: 'HTTP 代理',
metricHttps: 'HTTPS 代理',
metricTcpMux: 'TCP 多路复用',
metricTlsForce: '强制 TLS',
metricSubDomain: '根域名',
metricMaxPool: '最大连接池',
metricHeartbeat: '心跳超时',
bindAddr: '绑定地址',
bindPort: 'TCP 端口',
quicBindPort: 'QUIC 端口',
kcpBindPort: 'KCP 端口',
quicPort: 'QUIC 端口',
kcpPort: 'KCP 端口',
tcpMux: 'TCP 多路复用',
tlsForce: '强制 TLS',
vhostHTTPPort: 'HTTP 端口',
vhostHTTPSPort: 'HTTPS 端口',
subDomainHost: '根域名',
tipPortZero: '端口为 0 表示未配置',
hintClients: '当前在线控制连接',
hintProxies: '所有客户端注册的代理总数',
hintConns: '当前活跃的访客 / 代理连接',
hintBind: '主控制 / 工作连接端口',
hintMuxOn: 'yamux 已开启',
hintMuxOff: 'yamux 已关闭',
httpGwPort: 'HTTP 网关端口',
httpsGwPort: 'HTTPS 网关端口',
rootDomain: '根域名',
maxConnPool: '最大连接池',
heartbeatTimeout: '心跳超时',
version: '版本',
},
clients: {
runId: '运行 ID',
user: '用户',
hostname: '主机名',
ip: 'IP',
os: '操作系统',
arch: '架构',
osFamily: {
windows: 'Windows',
macos: 'macOS',
@@ -101,13 +62,10 @@ const zhCN: MessageSchema = {
freebsd: 'FreeBSD',
other: '未知设备',
},
version: '版本',
status: '状态',
proxies: '代理数',
tunnels: '隧道数',
connected: '已连接',
disconnected: '已断开',
connections: '连接数',
connectedSecs: '{n} 秒',
empty: '暂无客户端',
filter: '状态筛选',
filterAll: '全部',
@@ -127,28 +85,22 @@ const zhCN: MessageSchema = {
detail: '客户端详情',
notFound: '客户端不存在',
notFoundDesc: '该客户端可能已移除,或尚未连接过本服务。',
searchProxies: '搜索代理…',
proxiesEmpty: '该客户端暂无代理',
proxiesSearchEmpty: '没有匹配「{q}」的代理',
searchTunnels: '搜索隧道…',
tunnelsEmpty: '该客户端暂无隧道',
tunnelsSearchEmpty: '没有匹配「{q}」的隧道',
},
proxies: {
name: '名称',
type: '类型',
tunnels: {
port: '端口',
domain: '域名',
localAddr: '本地',
client: '客户端',
status: '状态',
empty: '暂无代理',
empty: '暂无隧道',
traffic: '今日流量',
trafficIn: '今日流入',
trafficOut: '今日流出',
curConns: '连接数',
total: '共 {n} 项',
activeConns: '连接数',
filter: '协议筛选',
filterAll: '全部',
filterEmpty: '当前协议下暂无代理',
back: '返回代理列表',
filterEmpty: '当前协议下暂无隧道',
back: '返回隧道列表',
lastStarted: '上次启动 {time}',
openClient: '查看客户端详情',
},
@@ -158,12 +110,8 @@ const zhCN: MessageSchema = {
total: '今日总流量',
today: '今日',
network: '网络流量',
networkDesc: '入站 = 访客 → 隧道;出站 = 隧道 → 访客',
history: '流量统计',
historyDesc: '按自然日统计,以工作连接方向计:流入=访客→隧道,流出=隧道→访客',
historyAll: '流量趋势',
historyAllDesc: '汇总全部代理近 7 个自然日入站 / 出站流量',
historyAllDesc24h: '滚动最近 24 小时(按小时桶),汇总全部代理',
range: '时间范围',
range24h: '24 小时',
range7d: '7 天',
@@ -179,7 +127,7 @@ const zhCN: MessageSchema = {
offline: '离线',
},
errors: {
unauthorized: '未授权 — 请检查 webServer.user / password',
unauthorized: '未授权 — 请检查 dashboard.user / password',
http: '{status} {statusText}',
api: '接口错误',
unknown: '未知错误',
+18 -70
View File
@@ -1,21 +1,13 @@
export interface MessageSchema {
app: {
brand: string
brandAccent: string
title: string
}
nav: {
menu: string
monitor: string
proxies: string
tunnels: string
clients: string
}
actions: {
refresh: string
themeToLight: string
themeToDark: string
themeLightHint: string
themeDarkHint: string
locale: string
github: string
collapseSidebar: string
@@ -34,63 +26,32 @@ export interface MessageSchema {
nextPage: string
}
overview: {
version: string
clients: string
totalClients: string
onlineClients: string
proxies: string
tunnels: string
connections: string
bindPort: string
server: string
emptyConfig: string
}
monitor: {
listen: string
listenDesc: string
vhost: string
vhostDesc: string
proxyTypes: string
proxyTypesDesc: string
proxyDist: string
proxyDistDesc: string
tunnelTypes: string
tunnelDist: string
serverConfig: string
chartTotal: string
metricAddr: string
metricTcp: string
metricWebsocket: string
metricQuic: string
metricKcp: string
metricHttp: string
metricHttps: string
metricTcpMux: string
metricTlsForce: string
metricSubDomain: string
metricMaxPool: string
metricHeartbeat: string
bindAddr: string
bindPort: string
quicBindPort: string
kcpBindPort: string
quicPort: string
kcpPort: string
tcpMux: string
tlsForce: string
vhostHTTPPort: string
vhostHTTPSPort: string
subDomainHost: string
tipPortZero: string
hintClients: string
hintProxies: string
hintConns: string
hintBind: string
hintMuxOn: string
hintMuxOff: string
httpGwPort: string
httpsGwPort: string
rootDomain: string
maxConnPool: string
heartbeatTimeout: string
version: string
}
clients: {
runId: string
user: string
hostname: string
ip: string
os: string
arch: string
osFamily: {
windows: string
macos: string
@@ -99,13 +60,10 @@ export interface MessageSchema {
freebsd: string
other: string
}
version: string
status: string
proxies: string
tunnels: string
connected: string
disconnected: string
connections: string
connectedSecs: string
empty: string
filter: string
filterAll: string
@@ -125,24 +83,18 @@ export interface MessageSchema {
detail: string
notFound: string
notFoundDesc: string
searchProxies: string
proxiesEmpty: string
proxiesSearchEmpty: string
searchTunnels: string
tunnelsEmpty: string
tunnelsSearchEmpty: string
}
proxies: {
name: string
type: string
tunnels: {
port: string
domain: string
localAddr: string
client: string
status: string
empty: string
traffic: string
trafficIn: string
trafficOut: string
curConns: string
total: string
activeConns: string
filter: string
filterAll: string
filterEmpty: string
@@ -156,12 +108,8 @@ export interface MessageSchema {
total: string
today: string
network: string
networkDesc: string
history: string
historyDesc: string
historyAll: string
historyAllDesc: string
historyAllDesc24h: string
range: string
range24h: string
range7d: string
+4 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import {RouterLink} from 'vue-router'
import ThemeToggle from '@/components/ThemeToggle.vue'
import LocaleSwitcher from '@/components/LocaleSwitcher.vue'
import {useLocale} from '@/composables/useLocale'
@@ -29,12 +30,12 @@ const {isMobile, mobileOpen, toggleCollapsed} = useSidebar()
</svg>
</button>
<div class="brand-block">
<RouterLink to="/" class="brand-block" :aria-label="t('nav.monitor')">
<img class="logo-img" :src="logoUrl" alt="Orbien"/>
<div class="brand-title" aria-label="Orbien">
<div class="brand-title" aria-hidden="true">
<span class="brand-orb">Orb</span><span class="brand-rest">ien</span>
</div>
</div>
</RouterLink>
</div>
<div class="actions">
+21 -18
View File
@@ -3,12 +3,23 @@ import { RouterLink, useRoute } from 'vue-router'
import { NAV_ITEMS } from '@/constants/menus'
import { useLocale } from '@/composables/useLocale'
import { useSidebar } from '@/composables/useSidebar'
import computerIcon from '@/assets/icon/computer.svg?raw'
import shareIcon from '@/assets/icon/share.svg?raw'
import userIcon from '@/assets/icon/user.svg?raw'
import arrowLeftIcon from '@/assets/icon/arrow-left.svg?raw'
import arrowRightIcon from '@/assets/icon/arrow-right.svg?raw'
const { t } = useLocale()
const route = useRoute()
const { collapsed, mobileOpen, isMobile, desktopCollapsed, toggleCollapsed, closeMobile } =
useSidebar()
const SIDEBAR_ICONS: Record<(typeof NAV_ITEMS)[number]['icon'], string> = {
monitor: computerIcon,
tunnels: shareIcon,
clients: userIcon,
}
function isActive(path: string) {
if (path === '/') return route.path === '/' || route.path === ''
return route.path === path || route.path.startsWith(`${path}/`)
@@ -45,20 +56,11 @@ function onNavigate() {
:title="t(`nav.${item.labelKey}`)"
@click="onNavigate"
>
<span class="side-icon" aria-hidden="true">
<svg v-if="item.icon === 'monitor'" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</svg>
<svg v-else-if="item.icon === 'proxies'" viewBox="0 0 24 24">
<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.2 1.2" />
<path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.2-1.2" />
</svg>
<svg v-else viewBox="0 0 24 24">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19c1.8-3.2 4.2-4.8 7-4.8s5.2 1.6 7 4.8" />
</svg>
</span>
<span
class="side-icon"
aria-hidden="true"
v-html="SIDEBAR_ICONS[item.icon]"
/>
<span v-show="!desktopCollapsed" class="side-label">{{ t(`nav.${item.labelKey}`) }}</span>
</RouterLink>
</nav>
@@ -71,10 +73,11 @@ function onNavigate() {
:title="collapsed ? t('actions.expandSidebar') : t('actions.collapseSidebar')"
@click="toggleCollapsed"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path v-if="collapsed" d="M9 6l6 6-6 6" />
<path v-else d="M15 6l-6 6 6 6" />
</svg>
<span
class="collapse-icon"
aria-hidden="true"
v-html="collapsed ? arrowRightIcon : arrowLeftIcon"
/>
</button>
</aside>
</template>
+5 -6
View File
@@ -1,7 +1,7 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import Monitor from '@/views/Monitor.vue'
import Proxies from '@/views/Proxies.vue'
import ProxyDetail from '@/views/ProxyDetail.vue'
import Tunnels from '@/views/Tunnels.vue'
import TunnelDetail from '@/views/TunnelDetail.vue'
import Clients from '@/views/Clients.vue'
import ClientDetail from '@/views/ClientDetail.vue'
@@ -9,10 +9,9 @@ export const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{path: '/', name: 'monitor', component: Monitor},
{path: '/proxies', name: 'proxies', component: Proxies},
{path: '/proxies/:name', name: 'proxy-detail', component: ProxyDetail},
{path: '/tunnels', name: 'tunnels', component: Tunnels},
{path: '/tunnels/:name', name: 'tunnel-detail', component: TunnelDetail},
{path: '/clients', name: 'clients', component: Clients},
{path: '/clients/:runId', name: 'client-detail', component: ClientDetail},
{path: '/overview', redirect: '/'},
{path: '/clients/:sessionId', name: 'client-detail', component: ClientDetail},
],
})
+8 -15
View File
@@ -1,6 +1,6 @@
import {reactive} from 'vue'
import {fetchClients, fetchProxies, fetchSystemInfo, isApiError, type ApiError} from '@/api'
import type {ClientInfo, ProxyInfo, SystemInfo} from '@/types/api'
import {fetchClients, fetchTunnels, fetchSystemInfo, isApiError, type ApiError} from '@/api'
import type {ClientInfo, TunnelInfo, SystemInfo} from '@/types/api'
export type DashboardError =
| { code: ApiError['code']; params?: Record<string, unknown> }
@@ -9,32 +9,28 @@ export type DashboardError =
const state = reactive({
info: null as SystemInfo | null,
clients: [] as ClientInfo[],
proxies: [] as ProxyInfo[],
loading: false,
tunnels: [] as TunnelInfo[],
error: null as DashboardError,
})
export function useDashboardStore() {
async function refresh() {
state.loading = true
state.error = null
try {
const [sys, cli, prox] = await Promise.all([
const [sys, cli, tun] = await Promise.all([
fetchSystemInfo(),
fetchClients(),
fetchProxies(),
fetchTunnels(),
])
state.info = sys
state.clients = cli.items ?? []
state.proxies = prox.items ?? []
state.tunnels = tun.items ?? []
} catch (e) {
if (isApiError(e)) {
state.error = {code: e.code, params: e.params}
} else {
state.error = {code: 'unknown'}
}
} finally {
state.loading = false
}
}
@@ -45,11 +41,8 @@ export function useDashboardStore() {
get clients() {
return state.clients
},
get proxies() {
return state.proxies
},
get loading() {
return state.loading
get tunnels() {
return state.tunnels
},
get error() {
return state.error
+18 -19
View File
@@ -18,64 +18,63 @@ export interface SystemInfo {
}
export interface SystemConfig {
bindAddr: string
bindPort: number
quicBindPort: number
kcpBindPort: number
vhostHTTPPort: number
vhostHTTPSPort: number
subDomainHost: string
listen: string
quicPort: number
kcpPort: number
httpGwPort: number
httpsGwPort: number
rootDomain: string
tcpMux: boolean
tlsForce: boolean
maxPoolCount: number
maxConnPool: number
heartbeatTimeout: number
}
export interface SystemStatus {
clientCounts: number
totalClientCounts: number
proxyTypeCount: Record<string, number>
curConns: number
tunnelTypeCount: Record<string, number>
activeConns: number
totalTrafficIn: number
totalTrafficOut: number
}
export interface ClientInfo {
runId: string
sessionId: string
user: string
hostname: string
os: string
arch: string
clientIP: string
version: string
proxyCount: number
curConns: number
tunnelCount: number
activeConns: number
connectedSecs: number
status: string
}
export interface ProxyInfo {
export interface TunnelInfo {
name: string
type: string
remoteAddr: string
localAddr: string
clientId: string
sessionId: string
status: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number
activeConns: number
lastStartTime?: string
}
export interface ProxyTrafficPoint {
export interface TunnelTrafficPoint {
date: string
trafficIn: number
trafficOut: number
}
export interface ProxyTrafficResp {
export interface TunnelTrafficResp {
name: string
unit: 'bytes' | string
granularity: 'day' | string
history: ProxyTrafficPoint[]
history: TunnelTrafficPoint[]
}
+3 -3
View File
@@ -30,17 +30,17 @@ export function formatText(value: string | null | undefined): string | null {
return value!.trim()
}
export function isHttpProxyType(type?: string | null): boolean {
export function isHttpTunnelType(type?: string | null): boolean {
const t = (type || '').toLowerCase()
return t === 'http' || t === 'https'
}
export function formatProxyEndpoint(
export function formatTunnelEndpoint(
type?: string | null,
remoteAddr?: string | null,
): string {
const raw = (remoteAddr || '').trim()
if (!raw) return '—'
if (isHttpProxyType(type)) return raw
if (isHttpTunnelType(type)) return raw
return raw.replace(/^:/, '') || '—'
}
-15
View File
@@ -1,15 +0,0 @@
export type {ThemeMode} from './theme'
export {
applyTheme,
initTheme,
resolveTheme,
toggleTheme,
} from './theme'
export {
formatPort,
formatProxyEndpoint,
formatText,
isHttpProxyType,
isUnsetPort,
isUnsetText,
} from './format'
-4
View File
@@ -21,7 +21,3 @@ export function formatArch(raw?: string | null): string {
if (s === 'i386' || s === 'i686' || s === 'x86') return 'x86'
return raw!.trim()
}
export function hasOsInfo(os?: string | null, arch?: string | null): boolean {
return Boolean((os && os.trim()) || (arch && arch.trim()))
}
-6
View File
@@ -22,12 +22,6 @@ export function applyTheme(mode: ThemeMode) {
localStorage.setItem(STORAGE_KEY, mode)
}
export function initTheme(): ThemeMode {
const mode = resolveTheme()
applyTheme(mode)
return mode
}
export function toggleTheme(current: ThemeMode): ThemeMode {
const next: ThemeMode = current === 'dark' ? 'light' : 'dark'
applyTheme(next)
+108 -124
View File
@@ -5,26 +5,29 @@ import AppIcon from '@/components/AppIcon.vue'
import OsBadge from '@/components/OsBadge.vue'
import PaginationBar from '@/components/PaginationBar.vue'
import TrafficIO from '@/components/TrafficIO.vue'
import {fetchClient, fetchProxies, kickClient} from '@/api'
import {fetchClient, fetchTunnels, kickClient} from '@/api'
import {ApiError} from '@/api/errors'
import type {ClientInfo, ProxyInfo} from '@/types/api'
import type {ClientInfo, TunnelInfo} from '@/types/api'
import {useLocale} from '@/composables/useLocale'
import {formatProxyEndpoint, isHttpProxyType} from '@/utils/format'
import {usePresence} from '@/composables/usePresence'
import {formatTunnelEndpoint, isHttpTunnelType} from '@/utils/format'
import searchIcon from '@/assets/icon/search.svg?raw'
const route = useRoute()
const router = useRouter()
const {t} = useLocale()
const {isOnline, statusLabel, formatSeen} = usePresence()
const runId = computed(() => String(route.params.runId || ''))
const sessionId = computed(() => String(route.params.sessionId || ''))
const client = ref<ClientInfo | null>(null)
const loading = ref(true)
const notFound = ref(false)
const kicking = ref(false)
const proxies = ref<ProxyInfo[]>([])
const proxiesLoading = ref(false)
const proxySearch = ref('')
const tunnels = ref<TunnelInfo[]>([])
const tunnelsLoading = ref(false)
const tunnelSearch = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -32,50 +35,26 @@ const ready = ref(false)
let refreshTimer: number | null = null
let searchDebounce: number | null = null
let proxyReqSeq = 0
function isOnline(raw?: string) {
return !raw || raw === 'online'
}
function statusLabel(raw?: string) {
if (isOnline(raw)) return t('status.online')
if (raw === 'offline') return t('status.offline')
return raw || t('status.offline')
}
function formatSeen(secs: number, online: boolean) {
const n = Math.max(0, Math.floor(secs || 0))
if (online) {
if (n < 60) return t('clients.uptimeSecs', {n})
if (n < 3600) return t('clients.uptimeMins', {n: Math.floor(n / 60)})
if (n < 86400) return t('clients.uptimeHours', {n: Math.floor(n / 3600)})
return t('clients.uptimeDays', {n: Math.floor(n / 86400)})
}
if (n < 60) return t('clients.agoSecs', {n})
if (n < 3600) return t('clients.agoMins', {n: Math.floor(n / 60)})
if (n < 86400) return t('clients.agoHours', {n: Math.floor(n / 3600)})
return t('clients.agoDays', {n: Math.floor(n / 86400)})
}
let tunnelReqSeq = 0
function goBack() {
if (window.history.length > 1) router.back()
else router.push({name: 'clients'})
}
function openProxy(name: string) {
router.push({name: 'proxy-detail', params: {name}})
function openTunnel(name: string) {
router.push({name: 'tunnel-detail', params: {name}})
}
function onKeyOpenProxy(evt: KeyboardEvent, name: string) {
function onKeyOpenTunnel(evt: KeyboardEvent, name: string) {
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault()
openProxy(name)
openTunnel(name)
}
}
async function loadClient() {
const id = runId.value
const id = sessionId.value
if (!id) {
loading.value = false
notFound.value = true
@@ -99,46 +78,46 @@ async function loadClient() {
}
}
async function loadProxies() {
const id = client.value?.runId || runId.value
async function loadTunnels() {
const id = client.value?.sessionId || sessionId.value
if (!id) return
const seq = ++proxyReqSeq
proxiesLoading.value = true
const seq = ++tunnelReqSeq
tunnelsLoading.value = true
try {
const q = proxySearch.value.trim()
const data = await fetchProxies({
const q = tunnelSearch.value.trim()
const data = await fetchTunnels({
page: page.value,
pageSize: pageSize.value,
clientId: id,
sessionId: id,
q: q || undefined,
})
if (seq !== proxyReqSeq) return
if (seq !== tunnelReqSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / Math.max(data.pageSize, 1)))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await loadProxies()
await loadTunnels()
return
}
proxies.value = data.items ?? []
tunnels.value = data.items ?? []
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch {
if (seq !== proxyReqSeq) return
if (seq !== tunnelReqSeq) return
} finally {
if (seq === proxyReqSeq) proxiesLoading.value = false
if (seq === tunnelReqSeq) tunnelsLoading.value = false
}
}
async function refreshAll() {
const ok = await loadClient()
if (ok) await loadProxies()
if (ok) await loadTunnels()
}
async function onKick() {
const id = client.value?.runId
const id = client.value?.sessionId
if (!id || kicking.value) return
if (!window.confirm(t('clients.kickConfirm'))) return
kicking.value = true
@@ -160,32 +139,32 @@ function clearSearchDebounce() {
}
}
watch(proxySearch, () => {
watch(tunnelSearch, () => {
if (!ready.value) return
clearSearchDebounce()
proxyReqSeq++
proxiesLoading.value = false
tunnelReqSeq++
tunnelsLoading.value = false
page.value = 1
searchDebounce = window.setTimeout(() => {
searchDebounce = null
loadProxies()
loadTunnels()
}, 300)
})
watch([page, pageSize], () => {
if (!ready.value) return
if (searchDebounce !== null) return
loadProxies()
loadTunnels()
})
watch(runId, async () => {
watch(sessionId, async () => {
if (!ready.value) return
loading.value = true
client.value = null
proxies.value = []
tunnels.value = []
total.value = 0
page.value = 1
proxySearch.value = ''
tunnelSearch.value = ''
await refreshAll()
})
@@ -216,7 +195,7 @@ onUnmounted(() => {
{{ t('nav.clients') }}
</button>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="crumb-current mono">{{ client?.runId || runId }}</span>
<span class="crumb-current mono">{{ client?.sessionId || sessionId }}</span>
</nav>
<div v-if="loading && !client" class="empty-card">{{ t('traffic.loading') }}</div>
@@ -230,7 +209,7 @@ onUnmounted(() => {
</div>
<div class="head-body">
<div class="title-row">
<h2 class="name mono">{{ client.runId }}</h2>
<h2 class="name mono">{{ client.sessionId }}</h2>
<span v-if="client.version" class="tag version">v{{ client.version }}</span>
<span v-if="client.user" class="tag">{{ client.user }}</span>
</div>
@@ -261,11 +240,11 @@ onUnmounted(() => {
<div class="info-row" role="list">
<div class="info-item" role="listitem">
<em>{{ t('clients.connections') }}</em>
<strong>{{ client.curConns ?? 0 }}</strong>
<strong>{{ client.activeConns ?? 0 }}</strong>
</div>
<div class="info-item" role="listitem">
<em>{{ t('clients.proxies') }}</em>
<strong>{{ client.proxyCount ?? 0 }}</strong>
<em>{{ t('clients.tunnels') }}</em>
<strong>{{ client.tunnelCount ?? 0 }}</strong>
</div>
<div class="info-item" role="listitem">
<em>{{ isOnline(client.status) ? t('clients.connected') : t('clients.disconnected') }}</em>
@@ -278,74 +257,71 @@ onUnmounted(() => {
</div>
</section>
<section class="proxies-panel card">
<div class="proxies-header">
<div class="proxies-title">
<h3>{{ t('nav.proxies') }}</h3>
<section class="tunnels-panel card">
<div class="tunnels-header">
<div class="tunnels-title">
<h3>{{ t('nav.tunnels') }}</h3>
<span class="count">{{ total }}</span>
</div>
<label class="search">
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="7" cy="7" r="4.5"/>
<path d="M10.5 10.5 14 14"/>
</svg>
<span class="search-icon" aria-hidden="true" v-html="searchIcon"/>
<input
v-model="proxySearch"
v-model="tunnelSearch"
type="search"
:placeholder="t('clients.searchProxies')"
:placeholder="t('clients.searchTunnels')"
autocomplete="off"
/>
</label>
</div>
<div v-if="proxiesLoading && !proxies.length" class="panel-empty">
<div v-if="tunnelsLoading && !tunnels.length" class="panel-empty">
{{ t('traffic.loading') }}
</div>
<div v-else-if="!proxies.length" class="panel-empty">
<div v-else-if="!tunnels.length" class="panel-empty">
{{
proxySearch.trim()
? t('clients.proxiesSearchEmpty', {q: proxySearch.trim()})
: t('clients.proxiesEmpty')
tunnelSearch.trim()
? t('clients.tunnelsSearchEmpty', {q: tunnelSearch.trim()})
: t('clients.tunnelsEmpty')
}}
</div>
<div v-else class="proxy-list">
<div v-else class="tunnel-list">
<article
v-for="p in proxies"
:key="`${p.name}:${p.clientId}`"
class="proxy-card"
v-for="p in tunnels"
:key="`${p.name}:${p.sessionId}`"
class="tunnel-card"
role="button"
tabindex="0"
@click="openProxy(p.name)"
@keydown="onKeyOpenProxy($event, p.name)"
@click="openTunnel(p.name)"
@keydown="onKeyOpenTunnel($event, p.name)"
>
<div class="proxy-main">
<div class="proxy-title">
<h3 class="proxy-name">{{ p.name }}</h3>
<span class="proxy-type">{{ (p.type || '—').toUpperCase() }}</span>
<div class="tunnel-main">
<div class="tunnel-title">
<h3 class="tunnel-name">{{ p.name }}</h3>
<span class="tunnel-type">{{ (p.type || '—').toUpperCase() }}</span>
</div>
<div class="proxy-meta">
<div class="tunnel-meta">
<span class="meta-endpoint">
<em>{{ isHttpProxyType(p.type) ? t('proxies.domain') : t('proxies.port') }}</em>
<code>{{ formatProxyEndpoint(p.type, p.remoteAddr) }}</code>
<em>{{ isHttpTunnelType(p.type) ? t('tunnels.domain') : t('tunnels.port') }}</em>
<code>{{ formatTunnelEndpoint(p.type, p.remoteAddr) }}</code>
</span>
<span class="meta-arrow" aria-hidden="true"></span>
<span class="meta-endpoint">
<em>{{ t('proxies.localAddr') }}</em>
<em>{{ t('tunnels.localAddr') }}</em>
<code>{{ p.localAddr || '—' }}</code>
</span>
<span>
<em>{{ t('proxies.curConns') }}</em>
{{ p.curConns ?? 0 }}
<em>{{ t('tunnels.activeConns') }}</em>
{{ p.activeConns ?? 0 }}
</span>
<span class="meta-client">
<em>{{ t('proxies.client') }}</em>
{{ p.clientId || '—' }}
<em>{{ t('tunnels.client') }}</em>
{{ p.sessionId || '—' }}
</span>
</div>
</div>
<div class="proxy-side">
<div class="tunnel-side">
<TrafficIO :traffic-in="p.todayTrafficIn" :traffic-out="p.todayTrafficOut"/>
<span class="status-badge" :class="{ online: isOnline(p.status) }">
{{ statusLabel(p.status) }}
@@ -418,7 +394,7 @@ onUnmounted(() => {
}
.summary,
.proxies-panel,
.tunnels-panel,
.not-found,
.empty-card {
background: var(--panel);
@@ -576,7 +552,7 @@ onUnmounted(() => {
word-break: break-all;
}
.proxies-panel {
.tunnels-panel {
display: flex;
flex-direction: column;
gap: 0;
@@ -584,7 +560,7 @@ onUnmounted(() => {
overflow: hidden;
}
.proxies-header {
.tunnels-header {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -594,13 +570,13 @@ onUnmounted(() => {
border-bottom: 1px solid var(--line);
}
.proxies-title {
.tunnels-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.proxies-title h3 {
.tunnels-title h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 650;
@@ -629,16 +605,24 @@ onUnmounted(() => {
background: color-mix(in srgb, var(--muted) 6%, transparent);
}
.search svg {
width: 0.95rem;
height: 0.95rem;
fill: none;
stroke: var(--muted);
stroke-width: 1.6;
stroke-linecap: round;
.search-icon {
width: 0.9rem;
height: 0.9rem;
display: inline-grid;
place-items: center;
line-height: 0;
color: var(--muted);
flex-shrink: 0;
}
.search-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
stroke: none;
}
.search input {
flex: 1;
min-width: 0;
@@ -661,14 +645,14 @@ onUnmounted(() => {
color: var(--muted);
}
.proxy-list {
.tunnel-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.9rem 1rem;
}
.proxy-card {
.tunnel-card {
display: flex;
align-items: center;
justify-content: space-between;
@@ -684,17 +668,17 @@ onUnmounted(() => {
transform 0.18s ease;
}
.proxy-card:hover {
.tunnel-card:hover {
border-color: var(--line-strong);
box-shadow: 0 6px 18px color-mix(in srgb, var(--text) 6%, transparent);
}
.proxy-card:focus-visible {
.tunnel-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: 2px;
}
.proxy-main {
.tunnel-main {
min-width: 0;
flex: 1;
display: flex;
@@ -702,14 +686,14 @@ onUnmounted(() => {
gap: 0.45rem;
}
.proxy-title {
.tunnel-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.55rem;
}
.proxy-name {
.tunnel-name {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
@@ -717,14 +701,14 @@ onUnmounted(() => {
letter-spacing: -0.01em;
}
.proxy-type {
.tunnel-type {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
}
.proxy-meta {
.tunnel-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
@@ -733,7 +717,7 @@ onUnmounted(() => {
color: var(--text);
}
.proxy-meta em {
.tunnel-meta em {
font-style: normal;
color: var(--muted);
margin-right: 0.3rem;
@@ -768,7 +752,7 @@ onUnmounted(() => {
word-break: break-all;
}
.proxy-side {
.tunnel-side {
display: flex;
align-items: center;
gap: 1.1rem;
@@ -795,7 +779,7 @@ onUnmounted(() => {
border-color: color-mix(in srgb, var(--status-ok) 22%, transparent);
}
.proxies-panel :deep(.pagination-bar) {
.tunnels-panel :deep(.pagination-bar) {
padding: 0 1rem 0.9rem;
border-top: 0;
}
@@ -847,13 +831,13 @@ onUnmounted(() => {
align-self: flex-end;
}
.proxy-card {
.tunnel-card {
flex-direction: column;
align-items: stretch;
gap: 0.85rem;
}
.proxy-side {
.tunnel-side {
justify-content: space-between;
}
}
+34 -50
View File
@@ -7,6 +7,8 @@ import PaginationBar from '@/components/PaginationBar.vue'
import {kickClient} from '@/api'
import {useDashboardStore} from '@/stores/dashboard'
import {useLocale} from '@/composables/useLocale'
import {usePresence} from '@/composables/usePresence'
import signalIcon from '@/assets/icon/signal.svg?raw'
type StatusFilter = 'all' | 'online' | 'offline'
@@ -15,16 +17,13 @@ const FILTERS: StatusFilter[] = ['all', 'online', 'offline']
const store = useDashboardStore()
const router = useRouter()
const {t} = useLocale()
const {isOnline, statusLabel, formatSeen} = usePresence()
const page = ref(1)
const pageSize = ref(10)
const statusFilter = ref<StatusFilter>('all')
const kicking = ref<string | null>(null)
function isOnline(raw?: string) {
return !raw || raw === 'online'
}
const filtered = computed(() => {
const list = store.clients
if (statusFilter.value === 'all') return list
@@ -68,45 +67,24 @@ function filterLabel(key: StatusFilter) {
return t('status.offline')
}
function statusLabel(raw?: string) {
if (isOnline(raw)) return t('status.online')
if (raw === 'offline') return t('status.offline')
return raw || t('status.offline')
function openDetail(sessionId: string) {
router.push({name: 'client-detail', params: {sessionId}})
}
/** Online: uptime. Offline: time since disconnect. */
function formatSeen(secs: number, online: boolean) {
const n = Math.max(0, Math.floor(secs || 0))
if (online) {
if (n < 60) return t('clients.uptimeSecs', {n})
if (n < 3600) return t('clients.uptimeMins', {n: Math.floor(n / 60)})
if (n < 86400) return t('clients.uptimeHours', {n: Math.floor(n / 3600)})
return t('clients.uptimeDays', {n: Math.floor(n / 86400)})
}
if (n < 60) return t('clients.agoSecs', {n})
if (n < 3600) return t('clients.agoMins', {n: Math.floor(n / 60)})
if (n < 86400) return t('clients.agoHours', {n: Math.floor(n / 3600)})
return t('clients.agoDays', {n: Math.floor(n / 86400)})
}
function openDetail(runId: string) {
router.push({name: 'client-detail', params: {runId}})
}
function onKeyOpen(evt: KeyboardEvent, runId: string) {
function onKeyOpen(evt: KeyboardEvent, sessionId: string) {
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault()
openDetail(runId)
openDetail(sessionId)
}
}
async function onKick(runId: string, evt: Event) {
async function onKick(sessionId: string, evt: Event) {
evt.stopPropagation()
if (kicking.value) return
if (!window.confirm(t('clients.kickConfirm'))) return
kicking.value = runId
kicking.value = sessionId
try {
await kickClient(runId)
await kickClient(sessionId)
await store.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -142,14 +120,14 @@ async function onKick(runId: string, evt: Event) {
<article
v-for="c in pageItems"
:key="c.runId"
:key="c.sessionId"
class="client-card"
:class="{ offline: !isOnline(c.status) }"
role="button"
tabindex="0"
:aria-label="t('clients.detail')"
@click="openDetail(c.runId)"
@keydown="onKeyOpen($event, c.runId)"
@click="openDetail(c.sessionId)"
@keydown="onKeyOpen($event, c.sessionId)"
>
<div class="client-left">
<div class="os-avatar" :class="{ online: isOnline(c.status) }" aria-hidden="true">
@@ -159,12 +137,12 @@ async function onKick(runId: string, evt: Event) {
<div class="client-body">
<div class="client-title">
<h3 class="client-id">{{ c.runId }}</h3>
<h3 class="client-id">{{ c.sessionId }}</h3>
<span v-if="c.hostname" class="tag">{{ c.hostname }}</span>
<span v-if="c.user" class="tag">{{ c.user }}</span>
<span v-if="c.version" class="tag version">v{{ c.version }}</span>
<span class="tag soft">
{{ t('clients.proxies') }} {{ c.proxyCount ?? 0 }}
{{ t('clients.tunnels') }} {{ c.tunnelCount ?? 0 }}
</span>
</div>
<div class="client-meta">
@@ -174,9 +152,7 @@ async function onKick(runId: string, evt: Event) {
</span>
<OsBadge :os="c.os" :arch="c.arch" text-only/>
<span class="seen">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 12V8.5M5.5 12V5M9 12V7M12.5 12V3.5"/>
</svg>
<span class="seen-icon" aria-hidden="true" v-html="signalIcon"/>
{{ formatSeen(c.connectedSecs, isOnline(c.status)) }}
</span>
</div>
@@ -188,10 +164,10 @@ async function onKick(runId: string, evt: Event) {
v-if="isOnline(c.status)"
type="button"
class="kick-btn"
:disabled="kicking === c.runId"
:disabled="kicking === c.sessionId"
:title="t('clients.kick')"
:aria-label="t('clients.kick')"
@click="onKick(c.runId, $event)"
@click="onKick(c.sessionId, $event)"
>
<AppIcon name="kick"/>
</button>
@@ -439,15 +415,23 @@ async function onKick(runId: string, evt: Event) {
gap: 0.3rem;
}
.seen svg {
width: 0.85rem;
height: 0.85rem;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
.seen-icon {
width: 0.82rem;
height: 0.82rem;
display: inline-grid;
place-items: center;
line-height: 0;
color: inherit;
opacity: 0.85;
flex-shrink: 0;
}
.seen-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
stroke: none;
}
.client-right {
+46 -40
View File
@@ -17,15 +17,11 @@ const {t} = useLocale()
const trafficRange = ref<TrafficRange>('24h')
const chartVariant = ref<'bar' | 'line'>('line')
const PROXY_COLORS: Record<string, string> = {
const TUNNEL_COLORS: Record<string, string> = {
http: '#3b82f6',
https: '#93c5fd',
tcp: '#cbd5e1',
udp: '#2dd4bf',
socks5: '#f97316',
file: '#fb7185',
stcp: '#a78bfa',
xtcp: '#f472b6',
}
const FALLBACK_COLORS = ['#60a5fa', '#34d399', '#fbbf24', '#f87171', '#818cf8', '#94a3b8']
@@ -41,19 +37,19 @@ const totalClients = computed(() =>
Math.max(status.value?.totalClientCounts ?? 0, onlineClients.value),
)
const proxyTotal = computed(() => {
const m = status.value?.proxyTypeCount || {}
const tunnelTotal = computed(() => {
const m = status.value?.tunnelTypeCount || {}
return Object.values(m).reduce((a, b) => a + b, 0)
})
const chartSlices = computed<ChartSlice[]>(() => {
const m = status.value?.proxyTypeCount || {}
const m = status.value?.tunnelTypeCount || {}
const entries = Object.entries(m).sort(([a], [b]) => a.localeCompare(b))
return entries.map(([key, value], i) => ({
key,
label: key,
value,
color: PROXY_COLORS[key.toLowerCase()] ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length]!,
color: TUNNEL_COLORS[key.toLowerCase()] ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length]!,
}))
})
@@ -77,53 +73,63 @@ const configFields = computed<ConfigField[]>(() => {
const c = cfg.value
if (!c) return []
const fields: ConfigField[] = [
{
key: 'listen',
label: t('monitor.bindAddr'),
type: 'raw',
value: `${c.bindAddr || '—'}:${c.bindPort ?? '—'}`,
},
]
const fields: ConfigField[] = []
if (!isUnsetPort(c.kcpBindPort)) {
const version = store.info?.version?.trim()
if (version) {
fields.push({
key: 'version',
label: t('monitor.version'),
type: 'raw',
value: version,
})
}
fields.push({
key: 'listen',
label: t('monitor.listen'),
type: 'raw',
value: c.listen || '—',
})
if (!isUnsetPort(c.kcpPort)) {
fields.push({
key: 'kcp',
label: t('monitor.kcpBindPort'),
label: t('monitor.kcpPort'),
type: 'port',
value: c.kcpBindPort,
value: c.kcpPort,
})
}
if (!isUnsetPort(c.quicBindPort)) {
if (!isUnsetPort(c.quicPort)) {
fields.push({
key: 'quic',
label: t('monitor.quicBindPort'),
label: t('monitor.quicPort'),
type: 'port',
value: c.quicBindPort,
value: c.quicPort,
})
}
if (!isUnsetPort(c.vhostHTTPPort)) {
if (!isUnsetPort(c.httpGwPort)) {
fields.push({
key: 'http',
label: t('monitor.vhostHTTPPort'),
label: t('monitor.httpGwPort'),
type: 'port',
value: c.vhostHTTPPort,
value: c.httpGwPort,
})
}
if (!isUnsetPort(c.vhostHTTPSPort)) {
if (!isUnsetPort(c.httpsGwPort)) {
fields.push({
key: 'https',
label: t('monitor.vhostHTTPSPort'),
label: t('monitor.httpsGwPort'),
type: 'port',
value: c.vhostHTTPSPort,
value: c.httpsGwPort,
})
}
if (!isUnsetText(c.subDomainHost ?? '')) {
if (!isUnsetText(c.rootDomain ?? '')) {
fields.push({
key: 'subdomain',
label: t('monitor.subDomainHost'),
key: 'rootDomain',
label: t('monitor.rootDomain'),
type: 'text',
value: c.subDomainHost,
value: c.rootDomain,
})
}
@@ -142,13 +148,13 @@ const configFields = computed<ConfigField[]>(() => {
},
{
key: 'pool',
label: t('monitor.metricMaxPool'),
label: t('monitor.maxConnPool'),
type: 'raw',
value: c.maxPoolCount ?? 0,
value: c.maxConnPool ?? 0,
},
{
key: 'heartbeat',
label: t('monitor.metricHeartbeat'),
label: t('monitor.heartbeatTimeout'),
type: 'raw',
value: formatHeartbeat(c.heartbeatTimeout),
},
@@ -171,11 +177,11 @@ const descItems = computed<DescItem[]>(() =>
<StatCard :label="t('overview.onlineClients')" icon="user" tone="green">
{{ onlineClients }}
</StatCard>
<StatCard :label="t('overview.proxies')" icon="proxies" tone="violet">
{{ proxyTotal }}
<StatCard :label="t('overview.tunnels')" icon="tunnels" tone="violet">
{{ tunnelTotal }}
</StatCard>
<StatCard :label="t('overview.connections')" icon="link" tone="orange">
{{ status?.curConns ?? 0 }}
{{ status?.activeConns ?? 0 }}
</StatCard>
</section>
@@ -187,7 +193,7 @@ const descItems = computed<DescItem[]>(() =>
<TrafficSummary :traffic-in="trafficIn" :traffic-out="trafficOut"/>
</SectionCard>
<SectionCard class="panel" :title="t('monitor.proxyDist')">
<SectionCard class="panel" :title="t('monitor.tunnelDist')">
<DonutChart :slices="chartSlices"/>
</SectionCard>
</div>
@@ -8,39 +8,32 @@ import TrafficIO from '@/components/TrafficIO.vue'
import type {TrafficRange} from '@/api/client'
import {useDashboardStore} from '@/stores/dashboard'
import {useLocale} from '@/composables/useLocale'
import {formatProxyEndpoint, isHttpProxyType} from '@/utils/format'
import {usePresence} from '@/composables/usePresence'
import {formatTunnelEndpoint, isHttpTunnelType} from '@/utils/format'
const route = useRoute()
const router = useRouter()
const store = useDashboardStore()
const {t} = useLocale()
const {isOnline, statusLabel} = usePresence()
const trafficRange = ref<TrafficRange>('24h')
const chartVariant = ref<'bar' | 'line'>('bar')
const name = computed(() => String(route.params.name || ''))
const proxy = computed(() => store.proxies.find((p) => p.name === name.value) || null)
function statusLabel(raw?: string) {
if (!raw || raw === 'online') return t('status.online')
return raw
}
function isOnline(raw?: string) {
return !raw || raw === 'online'
}
const tunnel = computed(() => store.tunnels.find((t) => t.name === name.value) || null)
function goBack() {
router.push({name: 'proxies'})
router.push({name: 'tunnels'})
}
function openClient(runId: string) {
if (!runId) return
router.push({name: 'client-detail', params: {runId}})
function openClient(sessionId: string) {
if (!sessionId) return
router.push({name: 'client-detail', params: {sessionId}})
}
</script>
<template>
<div class="detail">
<button class="back" type="button" @click="goBack"> {{ t('proxies.back') }}</button>
<button class="back" type="button" @click="goBack"> {{ t('tunnels.back') }}</button>
<section class="summary card">
<div class="summary-head">
@@ -50,32 +43,32 @@ function openClient(runId: string) {
</div>
<div class="head-body">
<div class="title-row">
<h2 class="name">{{ proxy?.name || name }}</h2>
<span class="type-badge">{{ (proxy?.type || '—').toUpperCase() }}</span>
<span class="status-badge" :class="{ online: isOnline(proxy?.status) }">
{{ statusLabel(proxy?.status) }}
<h2 class="name">{{ tunnel?.name || name }}</h2>
<span class="type-badge">{{ (tunnel?.type || '—').toUpperCase() }}</span>
<span class="status-badge" :class="{ online: isOnline(tunnel?.status) }">
{{ statusLabel(tunnel?.status) }}
</span>
</div>
<div class="meta">
<button
v-if="proxy?.clientId"
v-if="tunnel?.sessionId"
type="button"
class="meta-client"
:title="t('proxies.openClient')"
:aria-label="t('proxies.openClient')"
@click="openClient(proxy.clientId)"
:title="t('tunnels.openClient')"
:aria-label="t('tunnels.openClient')"
@click="openClient(tunnel.sessionId)"
>
<AppIcon name="monitor"/>
<code>{{ proxy.clientId }}</code>
<code>{{ tunnel.sessionId }}</code>
</button>
<span v-else class="meta-client is-empty">
<AppIcon name="monitor"/>
<code></code>
</span>
<template v-if="proxy?.lastStartTime">
<template v-if="tunnel?.lastStartTime">
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-text">
{{ t('proxies.lastStarted', {time: proxy.lastStartTime}) }}
{{ t('tunnels.lastStarted', {time: tunnel.lastStartTime}) }}
</span>
</template>
</div>
@@ -85,26 +78,26 @@ function openClient(runId: string) {
<div class="metrics" role="list">
<div class="metric" role="listitem">
<em>{{ isHttpProxyType(proxy?.type) ? t('proxies.domain') : t('proxies.port') }}</em>
<em>{{ isHttpTunnelType(tunnel?.type) ? t('tunnels.domain') : t('tunnels.port') }}</em>
<div class="metric-value mono">
{{ formatProxyEndpoint(proxy?.type, proxy?.remoteAddr) }}
{{ formatTunnelEndpoint(tunnel?.type, tunnel?.remoteAddr) }}
</div>
</div>
<div class="metric" role="listitem">
<em>{{ t('proxies.localAddr') }}</em>
<div class="metric-value mono">{{ proxy?.localAddr || '—' }}</div>
<em>{{ t('tunnels.localAddr') }}</em>
<div class="metric-value mono">{{ tunnel?.localAddr || '—' }}</div>
</div>
<div class="metric" role="listitem">
<em>{{ t('proxies.curConns') }}</em>
<div class="metric-value">{{ proxy?.curConns ?? 0 }}</div>
<em>{{ t('tunnels.activeConns') }}</em>
<div class="metric-value">{{ tunnel?.activeConns ?? 0 }}</div>
</div>
<div class="metric" role="listitem">
<em>{{ t('proxies.traffic') }}</em>
<em>{{ t('tunnels.traffic') }}</em>
<div class="metric-value">
<TrafficIO
layout="inline"
:traffic-in="proxy?.todayTrafficIn"
:traffic-out="proxy?.todayTrafficOut"
:traffic-in="tunnel?.todayTrafficIn"
:traffic-out="tunnel?.todayTrafficOut"
/>
</div>
</div>
@@ -153,7 +146,7 @@ function openClient(runId: string) {
</div>
</template>
<TrafficChart
:proxy-name="name"
:tunnel-name="name"
:range="trafficRange"
:variant="chartVariant"
:refresh-ms="5000"
@@ -5,7 +5,8 @@ import PaginationBar from '@/components/PaginationBar.vue'
import TrafficIO from '@/components/TrafficIO.vue'
import {useDashboardStore} from '@/stores/dashboard'
import {useLocale} from '@/composables/useLocale'
import {formatProxyEndpoint, isHttpProxyType} from '@/utils/format'
import {usePresence} from '@/composables/usePresence'
import {formatTunnelEndpoint, isHttpTunnelType} from '@/utils/format'
type ProtocolFilter = 'all' | 'tcp' | 'udp' | 'http' | 'https'
@@ -13,6 +14,7 @@ const PROTOCOLS: ProtocolFilter[] = ['all', 'tcp', 'udp', 'http', 'https']
const store = useDashboardStore()
const {t} = useLocale()
const {isOnline, statusLabel} = usePresence()
const router = useRouter()
const page = ref(1)
@@ -20,7 +22,7 @@ const pageSize = ref(10)
const protocol = ref<ProtocolFilter>('all')
const filtered = computed(() => {
const list = store.proxies
const list = store.tunnels
if (protocol.value === 'all') return list
return list.filter((p) => (p.type || '').toLowerCase() === protocol.value)
})
@@ -34,14 +36,14 @@ const pageItems = computed(() => {
const typeCounts = computed(() => {
const counts: Record<ProtocolFilter, number> = {
all: store.proxies.length,
all: store.tunnels.length,
tcp: 0,
udp: 0,
http: 0,
https: 0,
}
for (const p of store.proxies) {
const ty = (p.type || '').toLowerCase() as ProtocolFilter
for (const tunnel of store.tunnels) {
const ty = (tunnel.type || '').toLowerCase() as ProtocolFilter
if (ty in counts && ty !== 'all') counts[ty] += 1
}
return counts
@@ -57,21 +59,12 @@ watch(protocol, () => {
})
function protocolLabel(key: ProtocolFilter) {
if (key === 'all') return t('proxies.filterAll')
if (key === 'all') return t('tunnels.filterAll')
return key.toUpperCase()
}
function statusLabel(raw?: string) {
if (!raw || raw === 'online') return t('status.online')
return raw
}
function isOnline(raw?: string) {
return !raw || raw === 'online'
}
function openDetail(name: string) {
router.push({name: 'proxy-detail', params: {name}})
router.push({name: 'tunnel-detail', params: {name}})
}
function onKeyOpen(evt: KeyboardEvent, name: string) {
@@ -83,8 +76,8 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
</script>
<template>
<section class="proxy-list">
<div class="list-toolbar" role="group" :aria-label="t('proxies.filter')">
<section class="tunnel-list">
<div class="list-toolbar" role="group" :aria-label="t('tunnels.filter')">
<button
v-for="key in PROTOCOLS"
:key="key"
@@ -98,52 +91,52 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
</button>
</div>
<div v-if="!store.proxies.length" class="empty-card">
{{ t('proxies.empty') }}
<div v-if="!store.tunnels.length" class="empty-card">
{{ t('tunnels.empty') }}
</div>
<div v-else-if="!filtered.length" class="empty-card">
{{ t('proxies.filterEmpty') }}
{{ t('tunnels.filterEmpty') }}
</div>
<article
v-for="p in pageItems"
:key="`${p.name}:${p.clientId}`"
class="proxy-card"
v-for="tunnel in pageItems"
:key="`${tunnel.name}:${tunnel.sessionId}`"
class="tunnel-card"
role="button"
tabindex="0"
@click="openDetail(p.name)"
@keydown="onKeyOpen($event, p.name)"
@click="openDetail(tunnel.name)"
@keydown="onKeyOpen($event, tunnel.name)"
>
<div class="proxy-main">
<div class="proxy-title">
<h3 class="proxy-name">{{ p.name }}</h3>
<span class="proxy-type">{{ (p.type || '—').toUpperCase() }}</span>
<div class="tunnel-main">
<div class="tunnel-title">
<h3 class="tunnel-name">{{ tunnel.name }}</h3>
<span class="tunnel-type">{{ (tunnel.type || '—').toUpperCase() }}</span>
</div>
<div class="proxy-meta">
<div class="tunnel-meta">
<span class="meta-endpoint">
<em>{{ isHttpProxyType(p.type) ? t('proxies.domain') : t('proxies.port') }}</em>
<code>{{ formatProxyEndpoint(p.type, p.remoteAddr) }}</code>
<em>{{ isHttpTunnelType(tunnel.type) ? t('tunnels.domain') : t('tunnels.port') }}</em>
<code>{{ formatTunnelEndpoint(tunnel.type, tunnel.remoteAddr) }}</code>
</span>
<span class="meta-arrow" aria-hidden="true"></span>
<span class="meta-endpoint">
<em>{{ t('proxies.localAddr') }}</em>
<code>{{ p.localAddr || '—' }}</code>
<em>{{ t('tunnels.localAddr') }}</em>
<code>{{ tunnel.localAddr || '—' }}</code>
</span>
<span>
<em>{{ t('proxies.curConns') }}</em>
{{ p.curConns ?? 0 }}
<em>{{ t('tunnels.activeConns') }}</em>
{{ tunnel.activeConns ?? 0 }}
</span>
<span class="meta-client">
<em>{{ t('proxies.client') }}</em>
{{ p.clientId || '—' }}
<em>{{ t('tunnels.client') }}</em>
{{ tunnel.sessionId || '—' }}
</span>
</div>
</div>
<div class="proxy-side">
<TrafficIO :traffic-in="p.todayTrafficIn" :traffic-out="p.todayTrafficOut"/>
<span class="status-badge" :class="{ online: isOnline(p.status) }">
{{ statusLabel(p.status) }}
<div class="tunnel-side">
<TrafficIO :traffic-in="tunnel.todayTrafficIn" :traffic-out="tunnel.todayTrafficOut"/>
<span class="status-badge" :class="{ online: isOnline(tunnel.status) }">
{{ statusLabel(tunnel.status) }}
</span>
</div>
</article>
@@ -157,7 +150,7 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
</template>
<style scoped>
.proxy-list {
.tunnel-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -227,7 +220,7 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
box-shadow: var(--shadow);
}
.proxy-card {
.tunnel-card {
display: flex;
align-items: center;
justify-content: space-between;
@@ -243,17 +236,17 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
transform 0.18s ease;
}
.proxy-card:hover {
.tunnel-card:hover {
border-color: var(--line-strong);
box-shadow: 0 6px 18px color-mix(in srgb, var(--text) 6%, transparent);
}
.proxy-card:focus-visible {
.tunnel-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: 2px;
}
.proxy-main {
.tunnel-main {
min-width: 0;
flex: 1;
display: flex;
@@ -261,14 +254,14 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
gap: 0.45rem;
}
.proxy-title {
.tunnel-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.55rem;
}
.proxy-name {
.tunnel-name {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
@@ -276,14 +269,14 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
letter-spacing: -0.01em;
}
.proxy-type {
.tunnel-type {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
}
.proxy-meta {
.tunnel-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
@@ -292,7 +285,7 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
color: var(--text);
}
.proxy-meta em {
.tunnel-meta em {
font-style: normal;
color: var(--muted);
margin-right: 0.3rem;
@@ -333,7 +326,7 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
}
}
.proxy-side {
.tunnel-side {
display: flex;
align-items: center;
gap: 1.1rem;
@@ -361,13 +354,13 @@ function onKeyOpen(evt: KeyboardEvent, name: string) {
}
@media (max-width: 720px) {
.proxy-card {
.tunnel-card {
flex-direction: column;
align-items: stretch;
gap: 0.85rem;
}
.proxy-side {
.tunnel-side {
justify-content: space-between;
}
}