refactor: Optimize and improve the server-side admin panel.
|
Before Width: | Height: | Size: 12 KiB |
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "orbien-server-ui",
|
||||
"private": true,
|
||||
"version": "2.1.0-SNAPSHOT",
|
||||
"version": "3.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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'},
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '未知错误',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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,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[]
|
||||
}
|
||||
|
||||
@@ -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(/^:/, '') || '—'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||