From af0187af6cda5c0ed9c81c809d44c6e3f8a88cad Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 00:04:52 +0800 Subject: [PATCH 01/53] Update config.json --- public/config.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/public/config.json b/public/config.json index cd03653..4b5f702 100644 --- a/public/config.json +++ b/public/config.json @@ -4,9 +4,9 @@ "footer": "Powered by NodeGet", "site_tokens": [ { - "name": "master server node 1", - "backend_url": "wss://your-backend.example.com", - "token": "YOUR_TOKEN_HERE" + "name": "master server node Northflank", + "backend_url": "wss://p01--nodeget--746rkgpbkws5.code.run", + "token": "ILil9LWEY2OuI2bd:mhE99lkf90ThmrCPTvj13UQqQvE9YVJV" } ] -} \ No newline at end of file +} From f0bf83da98dac2b86cf187980bbc5f3fa0a22bb6 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 02:00:13 +0800 Subject: [PATCH 02/53] Delete public/config.json --- public/config.json | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 public/config.json diff --git a/public/config.json b/public/config.json deleted file mode 100644 index 4b5f702..0000000 --- a/public/config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "site_name": "NodeGet Status", - "site_logo": "", - "footer": "Powered by NodeGet", - "site_tokens": [ - { - "name": "master server node Northflank", - "backend_url": "wss://p01--nodeget--746rkgpbkws5.code.run", - "token": "ILil9LWEY2OuI2bd:mhE99lkf90ThmrCPTvj13UQqQvE9YVJV" - } - ] -} From cd3d409719418c8053ad1f8511dcbea2c2a84282 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:37:08 +0800 Subject: [PATCH 03/53] Update NodeCard.tsx --- src/components/NodeCard.tsx | 42 +------------------------------------ 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 31b866d..6fad540 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -22,7 +22,7 @@ export function NodeCard({ node }: { node: Node }) { @@ -56,7 +56,6 @@ export function NodeCard({ node }: { node: Node }) { sub={u.diskTotal ? `${bytes(u.diskUsed)} / ${bytes(u.diskTotal)}` : null} /> -
{bytes(u.netIn || 0)}/s @@ -81,42 +80,3 @@ export function NodeCard({ node }: { node: Node }) { ) } - -function Stat({ icon: Icon, children }: { icon: LucideIcon; children: ReactNode }) { - return ( - - - {children} - - ) -} - -function Metric({ - label, - value, - sub, - subTitle, - }: { - label: string - value: number | undefined - sub?: string | null - subTitle?: string -}) { - return ( -
-
- {label} - {pct(value)} -
- - {sub && ( -
- {sub} -
- )} -
- ) -} From e80e9136e7426a9fff8677c74908fc35f94ebfac Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:39:12 +0800 Subject: [PATCH 04/53] Update NodeDetail.tsx --- src/components/NodeDetail.tsx | 53 ++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 6906214..24fcccf 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -200,6 +200,13 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { )} +
+
+ + +
+
+ - - +@@ -231,50 +238,91 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) {
@@ -253,6 +258,47 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { ) } +function OnlinePanel({ online }: { online: boolean }) { + return ( +
+
+ 在线状态 + {online ? '100%' : '0%'} +
+
+ {Array.from({ length: 24 }).map((_, idx) => ( + + ))} +
+
+ ) +} + +function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) { + const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 3), [rows]) + return ( +
+
二网 TCPing
+ {stats.map(s => ( +
+ {s.name} +
+
+
+ {s.avg != null ? `${Math.round(s.avg)}ms` : '—'} +
+ ))} + {stats.length === 0 &&
暂无 TCP ping 数据
} +
+ ) +} + function Section({ title, children }: { title: string; children: ReactNode }) { return ( @@ -277,7 +323,6 @@ function Ring({ label, value, sub }: { label: string; value?: number; sub?: stri const c = 2 * Math.PI * r const v = Math.max(0, Math.min(100, value ?? 0)) const hasValue = Number.isFinite(value) - return (
From 9eec5ca01d9d6c912b5ef177646ba33a3e6c1d18 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:39:49 +0800 Subject: [PATCH 05/53] Update global.css --- src/styles/global.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/styles/global.css b/src/styles/global.css index 96e1db7..a733c11 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -104,3 +104,14 @@ -webkit-backdrop-filter: none; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } + +.cyber-card { + background: + linear-gradient(145deg, rgba(13, 27, 47, 0.92), rgba(19, 28, 50, 0.86)), + radial-gradient(circle at top right, rgba(0, 224, 255, 0.16), transparent 48%); + border-color: rgba(116, 187, 255, 0.28); + box-shadow: + inset 0 1px 0 rgba(187, 247, 255, 0.08), + 0 16px 32px -20px rgba(0, 0, 0, 0.75), + 0 0 0 1px rgba(41, 111, 187, 0.22); +} From 44c518d14716ad3d79dde3a5fd717f7a657fef4e Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:44:13 +0800 Subject: [PATCH 06/53] Update NodeDetail.tsx --- src/components/NodeDetail.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 24fcccf..c262953 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -232,7 +232,9 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { ? `${cpu.per_core.length} 核` : null } -@@ -231,50 +238,91 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { + /> +
+
From 7b9ba3bd660c5b1509c1fab0729929116b1f8704 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:52:57 +0800 Subject: [PATCH 07/53] Update NodeCard.tsx --- src/components/NodeCard.tsx | 128 ++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 6fad540..8be619f 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -9,7 +9,6 @@ import { cpuLabel, deriveUsage, displayName, distroLogo, osLabel, virtLabel } fr import { cn, loadColor } from '../utils/cn' import type { Node } from '../types' import type { ReactNode } from 'react' - export function NodeCard({ node }: { node: Node }) { const u = deriveUsage(node) const tags = Array.isArray(node.meta?.tags) ? node.meta.tags : [] @@ -17,66 +16,83 @@ export function NodeCard({ node }: { node: Node }) { const logo = distroLogo(node) const virt = virtLabel(node) const cpu = cpuLabel(node) - return ( - - -
- - {logo && ( - - )} - + + +
+ + {logo && } + {displayName(node)} - + +
+ {(os || virt) &&
{[os, virt].filter(Boolean).join(' · ')}
} +
+ + + +
+
+
+ {bytes(u.netIn || 0)}/s + {bytes(u.netOut || 0)}/s
- - {(os || virt) && ( -
- {[os, virt].filter(Boolean).join(' · ')} -
- )} - -
- - - +
+ {uptime(u.uptime)} + {relativeAge(u.ts)}
-
-
- {bytes(u.netIn || 0)}/s - {bytes(u.netOut || 0)}/s -
-
- {uptime(u.uptime)} - {relativeAge(u.ts)} -
+
+ {tags.length > 0 && ( +
+ {tags.map(t => ( + + {t} + + ))}
- - {tags.length > 0 && ( -
- {tags.map(t => ( - - {t} - - ))} -
- )} - -
+ )} + + + ) +} +function Metric({ + label, + value, + sub, + subTitle, +}: { + label: string + value?: number | null + sub?: ReactNode + subTitle?: string +}) { + const percent = pct(value) + return ( +
+
+ {label} + {sub ? ( + + {sub} + + ) : null} + {percent}% +
+ +
+ ) +} +function Stat({ icon: Icon, children }: { icon: LucideIcon; children: ReactNode }) { + return ( + + + {children} + ) } From 2c37d04e41d377db1c933745455ae38795232e6f Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:58:06 +0800 Subject: [PATCH 08/53] Enhance hover effects on NodeCard component --- src/components/NodeCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 8be619f..1d6e381 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -20,7 +20,8 @@ export function NodeCard({ node }: { node: Node }) { From 77ca1369a50cd4eecddcac80f269dd9de3152972 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 04:59:04 +0800 Subject: [PATCH 09/53] Update global.css --- src/styles/global.css | 88 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/styles/global.css b/src/styles/global.css index a733c11..a36eeba 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -89,6 +89,29 @@ background-size: 22px 22px; mask-image: radial-gradient(ellipse at top, black 40%, transparent 90%); } +.bg-soft::before, +.bg-soft::after { + content: ''; + position: fixed; + inset: -20%; + pointer-events: none; +} + +.bg-soft::before { + background: + radial-gradient(circle at 15% 20%, rgba(56, 189, 248, 0.2), transparent 28%), + radial-gradient(circle at 78% 12%, rgba(34, 197, 94, 0.14), transparent 26%), + radial-gradient(circle at 82% 82%, rgba(168, 85, 247, 0.16), transparent 32%); + animation: float-glow 24s ease-in-out infinite alternate; +} + +.bg-soft::after { + background-image: + linear-gradient(120deg, transparent 0%, rgba(59, 130, 246, 0.08) 45%, transparent 80%); + transform: translateX(-100%); + animation: bg-sweep 10s linear infinite; + opacity: 0.65; +} .card-soft { background-color: rgba(255, 255, 255, 0.78); @@ -106,6 +129,8 @@ } .cyber-card { + position: relative; + overflow: hidden; background: linear-gradient(145deg, rgba(13, 27, 47, 0.92), rgba(19, 28, 50, 0.86)), radial-gradient(circle at top right, rgba(0, 224, 255, 0.16), transparent 48%); @@ -115,3 +140,66 @@ 0 16px 32px -20px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(41, 111, 187, 0.22); } + +.cyber-card::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: linear-gradient(130deg, rgba(56, 189, 248, 0.5), rgba(59, 130, 246, 0), rgba(34, 197, 94, 0.45)); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0.42; + pointer-events: none; +} + +.cyber-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.18) 50%, transparent 78%); + transform: translateX(-130%); + transition: transform 0.9s ease; + pointer-events: none; +} + +.cyber-card:hover::after { + transform: translateX(130%); +} + +.cyber-card-active { + animation: card-breathe 3.8s ease-in-out infinite; +} + +@keyframes float-glow { + 0% { + transform: translate3d(-2%, -2%, 0) scale(1); + } + 100% { + transform: translate3d(3%, 2%, 0) scale(1.08); + } +} + +@keyframes bg-sweep { + 100% { + transform: translateX(100%); + } +} + +@keyframes card-breathe { + 0%, + 100% { + box-shadow: + inset 0 1px 0 rgba(187, 247, 255, 0.08), + 0 16px 32px -20px rgba(0, 0, 0, 0.75), + 0 0 0 1px rgba(41, 111, 187, 0.22); + } + 50% { + box-shadow: + inset 0 1px 0 rgba(187, 247, 255, 0.12), + 0 18px 36px -18px rgba(14, 116, 144, 0.52), + 0 0 0 1px rgba(56, 189, 248, 0.38); + } +} From e98254cb4e5ae55f58da6c6c48cca5e8d3135af0 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:03:53 +0800 Subject: [PATCH 10/53] Update NodeCard.tsx --- src/components/NodeCard.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 1d6e381..308388f 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -20,11 +20,12 @@ export function NodeCard({ node }: { node: Node }) { +
+
{logo && } From 2806f86486a4405b2f610b504ffca2b6161c2a38 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:05:49 +0800 Subject: [PATCH 11/53] Update global.css --- src/styles/global.css | 72 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index a36eeba..0ee5e59 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -74,12 +74,12 @@ } .bg-soft { - background-color: hsl(220 33% 98%); + background-color: hsl(226 64% 97%); background-image: - radial-gradient(60rem 36rem at 12% -10%, rgba(99, 102, 241, 0.16), transparent 60%), - radial-gradient(48rem 32rem at 95% 0%, rgba(56, 189, 248, 0.18), transparent 60%), - radial-gradient(42rem 28rem at 85% 95%, rgba(244, 114, 182, 0.12), transparent 60%), - radial-gradient(38rem 30rem at 5% 90%, rgba(34, 197, 94, 0.10), transparent 60%); + radial-gradient(64rem 40rem at 12% -10%, rgba(99, 102, 241, 0.26), transparent 60%), + radial-gradient(52rem 34rem at 95% 0%, rgba(56, 189, 248, 0.28), transparent 60%), + radial-gradient(46rem 30rem at 85% 95%, rgba(244, 114, 182, 0.22), transparent 60%), + radial-gradient(40rem 32rem at 5% 90%, rgba(16, 185, 129, 0.2), transparent 60%); background-attachment: fixed; } @@ -132,15 +132,16 @@ position: relative; overflow: hidden; background: - linear-gradient(145deg, rgba(13, 27, 47, 0.92), rgba(19, 28, 50, 0.86)), - radial-gradient(circle at top right, rgba(0, 224, 255, 0.16), transparent 48%); - border-color: rgba(116, 187, 255, 0.28); + linear-gradient(152deg, rgba(11, 23, 54, 0.92), rgba(34, 22, 73, 0.86)), + radial-gradient(circle at top right, rgba(0, 224, 255, 0.2), transparent 48%), + radial-gradient(circle at 12% 90%, rgba(244, 114, 182, 0.2), transparent 42%); + border-color: rgba(125, 211, 252, 0.35); box-shadow: inset 0 1px 0 rgba(187, 247, 255, 0.08), - 0 16px 32px -20px rgba(0, 0, 0, 0.75), - 0 0 0 1px rgba(41, 111, 187, 0.22); + 0 20px 36px -20px rgba(30, 41, 59, 0.72), + 0 0 0 1px rgba(56, 189, 248, 0.25), + 0 0 32px -18px rgba(99, 102, 241, 0.65); } - .cyber-card::before { content: ''; position: absolute; @@ -159,7 +160,7 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.18) 50%, transparent 78%); + background: linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.24) 44%, rgba(244, 114, 182, 0.22) 58%, transparent 78%); transform: translateX(-130%); transition: transform 0.9s ease; pointer-events: none; @@ -172,6 +173,37 @@ .cyber-card-active { animation: card-breathe 3.8s ease-in-out infinite; } +.cyber-orb { + position: absolute; + width: 8.25rem; + height: 8.25rem; + top: -2.6rem; + right: -1.2rem; + border-radius: 9999px; + background: radial-gradient(circle, rgba(56, 189, 248, 0.42) 0%, rgba(56, 189, 248, 0.14) 35%, transparent 72%); + filter: blur(1px); + opacity: 0.75; + animation: orb-float 7s ease-in-out infinite; + pointer-events: none; +} + +.cyber-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(125, 211, 252, 0.08) 1px, transparent 1px), + linear-gradient(to bottom, rgba(125, 211, 252, 0.07) 1px, transparent 1px); + background-size: 20px 20px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.38), transparent 72%); + opacity: 0.7; + animation: grid-drift 12s linear infinite; + pointer-events: none; +} + +.cyber-card > * { + position: relative; + z-index: 1; +} @keyframes float-glow { 0% { @@ -199,7 +231,19 @@ 50% { box-shadow: inset 0 1px 0 rgba(187, 247, 255, 0.12), - 0 18px 36px -18px rgba(14, 116, 144, 0.52), - 0 0 0 1px rgba(56, 189, 248, 0.38); + 0 18px 40px -16px rgba(14, 116, 144, 0.52), + 0 0 0 1px rgba(56, 189, 248, 0.45), + 0 0 42px -14px rgba(14, 165, 233, 0.75); } } + +@keyframes orb-float { + 0%, + 100% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(-10px, 8px, 0) scale(1.08); } +} + +@keyframes grid-drift { + 0% { transform: translate3d(0, 0, 0); } + 100% { transform: translate3d(20px, 20px, 0); } +} From ab7ea49a1b686aa43604f666e621d794800f7259 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:11:12 +0800 Subject: [PATCH 12/53] Update NodeCard.tsx --- src/components/NodeCard.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 308388f..53357f0 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -6,7 +6,7 @@ import { Flag } from './Flag' import { StatusDot } from './StatusDot' import { bytes, pct, relativeAge, uptime } from '../utils/format' import { cpuLabel, deriveUsage, displayName, distroLogo, osLabel, virtLabel } from '../utils/derive' -import { cn, loadColor } from '../utils/cn' +import { cn, loadColor, loadTextColor } from '../utils/cn' import type { Node } from '../types' import type { ReactNode } from 'react' export function NodeCard({ node }: { node: Node }) { @@ -20,27 +20,28 @@ export function NodeCard({ node }: { node: Node }) {
-
+
{logo && } - + {displayName(node)}
{(os || virt) &&
{[os, virt].filter(Boolean).join(' · ')}
} -
+
-
+
{bytes(u.netIn || 0)}/s {bytes(u.netOut || 0)}/s @@ -76,17 +77,21 @@ function Metric({ }) { const percent = pct(value) return ( -
+
- {label} + {label} {sub ? ( - + {sub} ) : null} - {percent}% + {percent}%
- +
) } From 3a988bd393d3b5eeb2b21daa7b45557e02b56f9c Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:12:32 +0800 Subject: [PATCH 13/53] Update global.css --- src/styles/global.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/styles/global.css b/src/styles/global.css index 0ee5e59..91f3372 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -142,6 +142,22 @@ 0 0 0 1px rgba(56, 189, 248, 0.25), 0 0 32px -18px rgba(99, 102, 241, 0.65); } + +.progress-glow { + position: relative; + box-shadow: + 0 0 10px rgba(45, 212, 191, 0.45), + 0 0 22px rgba(56, 189, 248, 0.3); +} + +.progress-glow::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.45) 48%, transparent 100%); + mix-blend-mode: screen; + animation: progress-scan 2.4s linear infinite; +} .cyber-card::before { content: ''; position: absolute; @@ -247,3 +263,8 @@ 0% { transform: translate3d(0, 0, 0); } 100% { transform: translate3d(20px, 20px, 0); } } + +@keyframes progress-scan { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(120%); } +} From 2e85b797c41cbebee5d40b5a60981f692e0d8a14 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:12:57 +0800 Subject: [PATCH 14/53] Update cn.ts --- src/utils/cn.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/utils/cn.ts b/src/utils/cn.ts index fe29ded..b3d099e 100644 --- a/src/utils/cn.ts +++ b/src/utils/cn.ts @@ -12,6 +12,13 @@ export function loadColor(v?: number | null) { return 'bg-emerald-500' } +export function loadTextColor(v?: number | null) { + if (v == null || !Number.isFinite(v)) return 'text-muted-foreground' + if (v >= 90) return 'text-rose-300' + if (v >= 70) return 'text-amber-300' + return 'text-emerald-300' +} + export function strokeColor(v?: number | null) { if (v == null || !Number.isFinite(v)) return 'stroke-muted-foreground/40' if (v >= 90) return 'stroke-rose-500' From e90d74b146865a179bfa8094b66a276eb02e98a0 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:17:28 +0800 Subject: [PATCH 15/53] Update NodeCard.tsx --- src/components/NodeCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 53357f0..f5fd5b2 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -20,7 +20,7 @@ export function NodeCard({ node }: { node: Node }) {
{(os || virt) &&
{[os, virt].filter(Boolean).join(' · ')}
} -
+
-
+
{bytes(u.netIn || 0)}/s {bytes(u.netOut || 0)}/s @@ -77,7 +77,7 @@ function Metric({ }) { const percent = pct(value) return ( -
+
{label} {sub ? ( From c836ed9e22dfe3c6d6ed5bcb02050c5fca45fed7 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:18:24 +0800 Subject: [PATCH 16/53] Update types.ts --- src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types.ts b/src/types.ts index bd8f96e..df5702a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,7 @@ export interface DynamicSummary { used_swap?: number total_swap?: number total_space?: number + used_space?: number available_space?: number receive_speed?: number transmit_speed?: number From bf6ed2e12db6b76cf55850cfa219aa868a967088 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 05:18:53 +0800 Subject: [PATCH 17/53] Update cn.ts --- src/utils/cn.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/cn.ts b/src/utils/cn.ts index b3d099e..2a592fc 100644 --- a/src/utils/cn.ts +++ b/src/utils/cn.ts @@ -7,9 +7,9 @@ export function cn(...inputs: ClassValue[]) { export function loadColor(v?: number | null) { if (v == null || !Number.isFinite(v)) return 'bg-muted-foreground/40' - if (v >= 90) return 'bg-rose-500' - if (v >= 70) return 'bg-amber-500' - return 'bg-emerald-500' + if (v >= 90) return 'bg-gradient-to-r from-fuchsia-500 via-rose-500 to-orange-500' + if (v >= 70) return 'bg-gradient-to-r from-amber-400 to-orange-500' + return 'bg-gradient-to-r from-emerald-400 to-cyan-400' } export function loadTextColor(v?: number | null) { From 11f5ab1b7f80e9ce93e8c72a51f7c856d59bb8b8 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 02:54:11 +0000 Subject: [PATCH 18/53] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=AF=86=E5=BA=A6=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8D=A0=E7=94=A8=E5=A1=AB=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/NodeCard.tsx | 8 ++++---- src/types.ts | 1 + src/utils/cn.ts | 6 +++--- src/utils/derive.ts | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 53357f0..f5fd5b2 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -20,7 +20,7 @@ export function NodeCard({ node }: { node: Node }) {
{(os || virt) &&
{[os, virt].filter(Boolean).join(' · ')}
} -
+
-
+
{bytes(u.netIn || 0)}/s {bytes(u.netOut || 0)}/s @@ -77,7 +77,7 @@ function Metric({ }) { const percent = pct(value) return ( -
+
{label} {sub ? ( diff --git a/src/types.ts b/src/types.ts index bd8f96e..df5702a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,7 @@ export interface DynamicSummary { used_swap?: number total_swap?: number total_space?: number + used_space?: number available_space?: number receive_speed?: number transmit_speed?: number diff --git a/src/utils/cn.ts b/src/utils/cn.ts index b3d099e..2a592fc 100644 --- a/src/utils/cn.ts +++ b/src/utils/cn.ts @@ -7,9 +7,9 @@ export function cn(...inputs: ClassValue[]) { export function loadColor(v?: number | null) { if (v == null || !Number.isFinite(v)) return 'bg-muted-foreground/40' - if (v >= 90) return 'bg-rose-500' - if (v >= 70) return 'bg-amber-500' - return 'bg-emerald-500' + if (v >= 90) return 'bg-gradient-to-r from-fuchsia-500 via-rose-500 to-orange-500' + if (v >= 70) return 'bg-gradient-to-r from-amber-400 to-orange-500' + return 'bg-gradient-to-r from-emerald-400 to-cyan-400' } export function loadTextColor(v?: number | null) { diff --git a/src/utils/derive.ts b/src/utils/derive.ts index df59760..182155e 100644 --- a/src/utils/derive.ts +++ b/src/utils/derive.ts @@ -2,10 +2,10 @@ import type { Node, Usage } from '../types' export function deriveUsage(node: Node): Usage { const d = node.dynamic - const memUsed = d?.used_memory ?? 0 const memTotal = d?.total_memory ?? 0 + const memUsed = d?.used_memory ?? (memTotal && d?.available_memory != null ? memTotal - d.available_memory : 0) const diskTotal = d?.total_space ?? 0 - const diskUsed = diskTotal && d?.available_space != null ? diskTotal - d.available_space : 0 + const diskUsed = d?.used_space ?? (diskTotal && d?.available_space != null ? diskTotal - d.available_space : 0) return { cpu: d?.cpu_usage, mem: memTotal ? (memUsed / memTotal) * 100 : undefined, From 9be79e97f1d7fa061d7876a226d39acca282a931 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 03:01:12 +0000 Subject: [PATCH 19/53] Fix usage bars updates and refresh card/latency visuals --- src/components/NodeCard.tsx | 11 ++++++----- src/components/NodeDetail.tsx | 27 ++++++++++++++++----------- src/utils/cn.ts | 10 +++++----- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index f5fd5b2..79e7bab 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -75,7 +75,8 @@ function Metric({ sub?: ReactNode subTitle?: string }) { - const percent = pct(value) + const numericValue = Number.isFinite(value) ? (value as number) : undefined + const percent = pct(numericValue) return (
@@ -85,12 +86,12 @@ function Metric({ {sub} ) : null} - {percent}% + {percent}
) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index c262953..67db19d 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -280,20 +280,25 @@ function OnlinePanel({ online }: { online: boolean }) { } function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) { - const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 3), [rows]) + const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 5), [rows]) return ( -
-
二网 TCPing
+
+
TCP Ping 质量
{stats.map(s => ( -
- {s.name} -
-
+
+
+ {s.name} +
+
+
+ {s.avg != null ? `${Math.round(s.avg)}ms` : '—'} +
+
+ min {s.min != null ? `${Math.round(s.min)}ms` : '—'} · max {s.max != null ? `${Math.round(s.max)}ms` : '—'} · 丢包 {s.loss.toFixed(1)}%
- {s.avg != null ? `${Math.round(s.avg)}ms` : '—'}
))} {stats.length === 0 &&
暂无 TCP ping 数据
} diff --git a/src/utils/cn.ts b/src/utils/cn.ts index 2a592fc..ee69393 100644 --- a/src/utils/cn.ts +++ b/src/utils/cn.ts @@ -7,21 +7,21 @@ export function cn(...inputs: ClassValue[]) { export function loadColor(v?: number | null) { if (v == null || !Number.isFinite(v)) return 'bg-muted-foreground/40' - if (v >= 90) return 'bg-gradient-to-r from-fuchsia-500 via-rose-500 to-orange-500' - if (v >= 70) return 'bg-gradient-to-r from-amber-400 to-orange-500' - return 'bg-gradient-to-r from-emerald-400 to-cyan-400' + if (v >= 90) return 'bg-gradient-to-r from-rose-500 via-orange-500 to-amber-400' + if (v >= 70) return 'bg-gradient-to-r from-amber-400 to-yellow-300' + return 'bg-gradient-to-r from-sky-500 to-cyan-300' } export function loadTextColor(v?: number | null) { if (v == null || !Number.isFinite(v)) return 'text-muted-foreground' if (v >= 90) return 'text-rose-300' if (v >= 70) return 'text-amber-300' - return 'text-emerald-300' + return 'text-cyan-300' } export function strokeColor(v?: number | null) { if (v == null || !Number.isFinite(v)) return 'stroke-muted-foreground/40' if (v >= 90) return 'stroke-rose-500' if (v >= 70) return 'stroke-amber-500' - return 'stroke-emerald-500' + return 'stroke-sky-500' } From a76495c942f2da9bab528181f8327d2be862256c Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 03:08:17 +0000 Subject: [PATCH 20/53] feat: enhance cyber card gloss and disable offline selection --- src/components/NodeCard.tsx | 13 ++++++++----- src/styles/global.css | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 79e7bab..30bf8e7 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -16,13 +16,16 @@ export function NodeCard({ node }: { node: Node }) { const logo = distroLogo(node) const virt = virtLabel(node) const cpu = cpuLabel(node) + const Wrapper = node.online ? 'a' : 'div' + const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.uuid)}` } : {} + return ( - +
@@ -61,7 +64,7 @@ export function NodeCard({ node }: { node: Node }) {
)}
-
+ ) } function Metric({ diff --git a/src/styles/global.css b/src/styles/global.css index 91f3372..49a6dc3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -141,6 +141,7 @@ 0 20px 36px -20px rgba(30, 41, 59, 0.72), 0 0 0 1px rgba(56, 189, 248, 0.25), 0 0 32px -18px rgba(99, 102, 241, 0.65); + isolation: isolate; } .progress-glow { @@ -186,6 +187,25 @@ transform: translateX(130%); } +.cyber-card::selection { + background: transparent; +} + +.cyber-card-active::before { + opacity: 0.56; +} + +.cyber-card-active::after { + background: + linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.26) 44%, rgba(244, 114, 182, 0.24) 58%, transparent 78%), + linear-gradient(165deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.02) 46%, transparent 72%); +} + +.cyber-card-offline::before, +.cyber-card-offline::after { + display: none; +} + .cyber-card-active { animation: card-breathe 3.8s ease-in-out infinite; } From 7e4bf341d39d8968c69b2cab9acea837756ac79b Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 03:15:12 +0000 Subject: [PATCH 21/53] Fix TCP latency parsing and refine detail panel UX --- src/components/NodeDetail.tsx | 32 +++++++++++++++++--------------- src/styles/global.css | 16 +++++----------- src/utils/latency.ts | 28 ++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 67db19d..873d82b 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -139,7 +139,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) {
-
+
@@ -201,9 +201,11 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { )}
-
+
- +
+ +
@@ -215,7 +217,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { /> -
+
@@ -267,11 +269,11 @@ function OnlinePanel({ online }: { online: boolean }) { 在线状态 {online ? '100%' : '0%'}
-
+
{Array.from({ length: 24 }).map((_, idx) => ( ))}
@@ -282,21 +284,21 @@ function OnlinePanel({ online }: { online: boolean }) { function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) { const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 5), [rows]) return ( -
+
TCP Ping 质量
{stats.map(s => (
-
- {s.name} +
+ {s.name}
- {s.avg != null ? `${Math.round(s.avg)}ms` : '—'} + {s.avg != null ? `${Math.round(s.avg)}ms` : '—'}
-
+
min {s.min != null ? `${Math.round(s.min)}ms` : '—'} · max {s.max != null ? `${Math.round(s.max)}ms` : '—'} · 丢包 {s.loss.toFixed(1)}%
@@ -441,7 +443,7 @@ function LatencyBlock({ title, rows, type, loading }: LatencyBlockProps) { return (
-
+
{empty && (
{loading ? '加载中…' : `暂无 ${type} 数据`} @@ -492,14 +494,14 @@ function LatencyBlock({ title, rows, type, loading }: LatencyBlockProps) {
{stats.length > 0 && ( -
-
+
+
来源 平均延迟 抖动 丢包率
-
+
{stats.map(s => ( + for (const key of keys) { + const nv = nested[key] + if (typeof nv === 'number') return nv + } + } + } + + return null } function seriesNames(rows: TaskQueryResult[]) { From c463d246e3a0c664bca6bf923c7066a3b3fb7e3d Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 03:26:50 +0000 Subject: [PATCH 22/53] Revamp status panel and improve TCP latency parsing --- src/components/NodeDetail.tsx | 65 ++++++++++++++++++++++++++++------- src/utils/latency.ts | 24 +++++++++---- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 873d82b..6622139 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -201,8 +201,10 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { )}
-
- +
+
+ +
@@ -262,21 +264,60 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { ) } -function OnlinePanel({ online }: { online: boolean }) { +type HourState = 'online' | 'partial_offline' | 'fully_offline' + +function buildHourState(rows: TaskQueryResult[]): HourState[] { + const now = Date.now() + const states: HourState[] = [] + for (let i = 23; i >= 0; i--) { + const start = now - (i + 1) * 60 * 60 * 1000 + const end = now - i * 60 * 60 * 1000 + const bucket = rows.filter(r => { + const ts = r.timestamp < 1_000_000_000_000 ? r.timestamp * 1000 : r.timestamp + return ts >= start && ts < end + }) + if (!bucket.length) { + states.push('fully_offline') + continue + } + const fail = bucket.filter(r => !r.success).length + if (fail === 0) states.push('online') + else if (fail === bucket.length) states.push('fully_offline') + else states.push('partial_offline') + } + return states +} + +function OnlinePanel({ rows }: { rows: TaskQueryResult[] }) { + const states = useMemo(() => buildHourState(rows), [rows]) + const onlineHours = states.filter(s => s === 'online').length + const onlineRatio = Math.round((onlineHours / 24) * 100) + return ( -
+
- 在线状态 - {online ? '100%' : '0%'} + 在线状态 · 24小时 + {onlineRatio}%
-
- {Array.from({ length: 24 }).map((_, idx) => ( +
+ {states.map((state, idx) => ( ))}
+
+ 在线 + 有离线 + 整小时离线 +
) } @@ -284,8 +325,8 @@ function OnlinePanel({ online }: { online: boolean }) { function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) { const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 5), [rows]) return ( -
-
TCP Ping 质量
+
+
TCP Ping 质量 / HTOP 风格
{stats.map(s => (
@@ -299,7 +340,7 @@ function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) { {s.avg != null ? `${Math.round(s.avg)}ms` : '—'}
- min {s.min != null ? `${Math.round(s.min)}ms` : '—'} · max {s.max != null ? `${Math.round(s.max)}ms` : '—'} · 丢包 {s.loss.toFixed(1)}% + jitter {s.jitter != null ? `${Math.round(s.jitter)}ms` : '—'} · 丢包 {s.lossRate.toFixed(1)}%
))} diff --git a/src/utils/latency.ts b/src/utils/latency.ts index ef7eb11..71ebcf3 100644 --- a/src/utils/latency.ts +++ b/src/utils/latency.ts @@ -28,21 +28,31 @@ function pickValue(row: TaskQueryResult, type: LatencyType): number | null { const keys = type === 'tcp_ping' - ? ['tcp_ping', 'tcpPing', 'tcp', 'latency', 'value', 'delay'] - : ['ping', 'icmp_ping', 'icmpPing', 'latency', 'value', 'delay'] + ? ['tcp_ping', 'tcpPing', 'tcp', 'latency', 'value', 'delay', 'avg', 'min', 'time', 'duration'] + : ['ping', 'icmp_ping', 'icmpPing', 'latency', 'value', 'delay', 'avg', 'min', 'time', 'duration'] + + const toNum = (v: unknown) => { + if (typeof v === 'number') return Number.isFinite(v) ? v : null + if (typeof v === 'string') { + const n = Number(v) + return Number.isFinite(n) ? n : null + } + return null + } for (const key of keys) { - const v = payload[key] - if (typeof v === 'number') return v + const v = toNum(payload[key]) + if (v != null) return v } for (const v of Object.values(payload)) { - if (typeof v === 'number') return v + const top = toNum(v) + if (top != null) return top if (v && typeof v === 'object') { const nested = v as Record for (const key of keys) { - const nv = nested[key] - if (typeof nv === 'number') return nv + const nv = toNum(nested[key]) + if (nv != null) return nv } } } From 761baffbec89eff5c1154a0e8840ceab5053bef9 Mon Sep 17 00:00:00 2001 From: Flanker <6418075+podcctv@users.noreply.github.com> Date: Wed, 6 May 2026 03:34:43 +0000 Subject: [PATCH 23/53] Fix node identity collisions and detail latency data --- src/App.tsx | 2 +- src/components/NodeCard.tsx | 2 +- src/components/NodeTable.tsx | 6 +- src/components/WorldMap.tsx | 6 +- src/hooks/useNodes.ts | 266 +++++++++++------------------------ src/types.ts | 1 + 6 files changed, 93 insertions(+), 190 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dd36cf9..73cf9bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -155,7 +155,7 @@ export function App() { }) }, [nodes, query, activeTag, activeRegion, sort, regions]) - const selectedNode = selected ? nodes.get(selected) || null : null + const selectedNode = selected ? nodes.get(selected) || [...nodes.values()].find(n => n.uuid === selected) || null : null if (configError) { return ( diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 30bf8e7..267be04 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -17,7 +17,7 @@ export function NodeCard({ node }: { node: Node }) { const virt = virtLabel(node) const cpu = cpuLabel(node) const Wrapper = node.online ? 'a' : 'div' - const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.uuid)}` } : {} + const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.id)}` } : {} return ( diff --git a/src/components/NodeTable.tsx b/src/components/NodeTable.tsx index 0805422..f5a04ee 100644 --- a/src/components/NodeTable.tsx +++ b/src/components/NodeTable.tsx @@ -11,7 +11,7 @@ import type { Node } from '../types' interface Props { nodes: Node[] - onOpen?: (uuid: string) => void + onOpen?: (id: string) => void } export function NodeTable({ nodes, onOpen }: Props) { @@ -39,8 +39,8 @@ export function NodeTable({ nodes, onOpen }: Props) { const virt = virtLabel(n) return ( onOpen?.(n.uuid)} + key={n.id} + onClick={() => onOpen?.(n.id)} className={cn('cursor-pointer', !n.online && 'opacity-60')} > diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 877574e..d1b33d5 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -6,7 +6,7 @@ import type { Node } from '../types' interface Props { nodes: Node[] - onOpen?: (uuid: string) => void + onOpen?: (id: string) => void } const MAP_W = 900 @@ -135,7 +135,7 @@ export function WorldMap({ nodes, onOpen }: Props) { onMouseLeave={scheduleClose} onClick={(e: any) => { e.stopPropagation?.() - if (!isCluster) onOpen?.(node.uuid) + if (!isCluster) onOpen?.(node.id) }} style={CURSOR} > @@ -227,7 +227,7 @@ function ClusterList({ lng: number state: 'open' | 'closed' onAnimationEnd?: () => void - onPick: (uuid: string) => void + onPick: (id: string) => void onMouseEnter?: () => void onMouseLeave?: () => void }) { diff --git a/src/hooks/useNodes.ts b/src/hooks/useNodes.ts index c43b3ae..4b22bc3 100644 --- a/src/hooks/useNodes.ts +++ b/src/hooks/useNodes.ts @@ -4,7 +4,7 @@ import { dynamicSummaryMulti, kvGetMulti, listAgentUuids, staticDataMulti } from import { isOnline } from '../utils/status' import type { DynamicSummary, HistorySample, Node, NodeMeta, SiteConfig } from '../types' -type Agent = Pick +type Agent = Pick interface BackendError { source: string @@ -13,103 +13,32 @@ interface BackendError { const STATIC_FIELDS = ['cpu', 'system'] const DYNAMIC_FIELDS = [ - 'cpu_usage', - 'used_memory', - 'total_memory', - 'available_memory', - 'used_swap', - 'total_swap', - 'total_space', - 'available_space', - 'read_speed', - 'write_speed', - 'receive_speed', - 'transmit_speed', - 'total_received', - 'total_transmitted', - 'load_one', - 'load_five', - 'load_fifteen', - 'uptime', - 'boot_time', - 'process_count', - 'tcp_connections', - 'udp_connections', + 'cpu_usage', 'used_memory', 'total_memory', 'available_memory', 'used_swap', 'total_swap', + 'total_space', 'available_space', 'read_speed', 'write_speed', 'receive_speed', 'transmit_speed', + 'total_received', 'total_transmitted', 'load_one', 'load_five', 'load_fifteen', 'uptime', 'boot_time', + 'process_count', 'tcp_connections', 'udp_connections', ] const META_KEYS = [ - 'metadata_name', - 'metadata_region', - 'metadata_tags', - 'metadata_hidden', - 'metadata_virtualization', - 'metadata_latitude', - 'metadata_longitude', - 'metadata_order', - 'metadata_price', - 'metadata_price_unit', - 'metadata_price_cycle', - 'metadata_expire_time', + 'metadata_name', 'metadata_region', 'metadata_tags', 'metadata_hidden', 'metadata_virtualization', + 'metadata_latitude', 'metadata_longitude', 'metadata_order', 'metadata_price', 'metadata_price_unit', + 'metadata_price_cycle', 'metadata_expire_time', ] const DYN_INTERVAL_MS = 2000 const HISTORY_LIMIT = 60 -function emptyMeta(): NodeMeta { - return { - name: '', - region: '', - tags: [], - hidden: false, - virtualization: '', - lat: null, - lng: null, - order: 0, - price: 0, - priceUnit: '$', - priceCycle: 30, - expireTime: '', - } -} +const nodeId = (source: string, uuid: string) => `${source}::${uuid}` -function blankAgent(uuid: string, source: string): Agent { - return { uuid, source, meta: emptyMeta(), static: {} } -} +function emptyMeta(): NodeMeta { return { name: '', region: '', tags: [], hidden: false, virtualization: '', lat: null, lng: null, order: 0, price: 0, priceUnit: '$', priceCycle: 30, expireTime: '' } } +function blankAgent(uuid: string, source: string): Agent { return { id: nodeId(source, uuid), uuid, source, meta: emptyMeta(), static: {} } } function parseMeta(raw: Record): NodeMeta { - const lat = Number(raw.metadata_latitude) - const lng = Number(raw.metadata_longitude) - const order = Number(raw.metadata_order) - const price = Number(raw.metadata_price) - const cycle = Number(raw.metadata_price_cycle) - return { - name: raw.metadata_name ? String(raw.metadata_name) : '', - region: raw.metadata_region ? String(raw.metadata_region) : '', - tags: Array.isArray(raw.metadata_tags) ? raw.metadata_tags.filter(Boolean) : [], - hidden: Boolean(raw.metadata_hidden), - virtualization: raw.metadata_virtualization ? String(raw.metadata_virtualization) : '', - lat: Number.isFinite(lat) ? lat : null, - lng: Number.isFinite(lng) ? lng : null, - order: Number.isFinite(order) ? order : 0, - price: Number.isFinite(price) ? price : 0, - priceUnit: raw.metadata_price_unit ? String(raw.metadata_price_unit) : '$', - priceCycle: Number.isFinite(cycle) && cycle > 0 ? cycle : 30, - expireTime: raw.metadata_expire_time ? String(raw.metadata_expire_time) : '', - } + const lat = Number(raw.metadata_latitude), lng = Number(raw.metadata_longitude), order = Number(raw.metadata_order), price = Number(raw.metadata_price), cycle = Number(raw.metadata_price_cycle) + return { name: raw.metadata_name ? String(raw.metadata_name) : '', region: raw.metadata_region ? String(raw.metadata_region) : '', tags: Array.isArray(raw.metadata_tags) ? raw.metadata_tags.filter(Boolean) : [], hidden: Boolean(raw.metadata_hidden), virtualization: raw.metadata_virtualization ? String(raw.metadata_virtualization) : '', lat: Number.isFinite(lat) ? lat : null, lng: Number.isFinite(lng) ? lng : null, order: Number.isFinite(order) ? order : 0, price: Number.isFinite(price) ? price : 0, priceUnit: raw.metadata_price_unit ? String(raw.metadata_price_unit) : '$', priceCycle: Number.isFinite(cycle) && cycle > 0 ? cycle : 30, expireTime: raw.metadata_expire_time ? String(raw.metadata_expire_time) : '' } } function sampleFrom(row: DynamicSummary): HistorySample { - const memTotal = row.total_memory || 0 - const diskTotal = row.total_space || 0 - return { - t: row.timestamp, - cpu: row.cpu_usage ?? null, - mem: memTotal && row.used_memory != null ? (row.used_memory / memTotal) * 100 : null, - disk: - diskTotal && row.available_space != null - ? ((diskTotal - row.available_space) / diskTotal) * 100 - : null, - netIn: row.receive_speed ?? 0, - netOut: row.transmit_speed ?? 0, - } + const memTotal = row.total_memory || 0, diskTotal = row.total_space || 0 + return { t: row.timestamp, cpu: row.cpu_usage ?? null, mem: memTotal && row.used_memory != null ? (row.used_memory / memTotal) * 100 : null, disk: diskTotal && row.available_space != null ? ((diskTotal - row.available_space) / diskTotal) * 100 : null, netIn: row.receive_speed ?? 0, netOut: row.transmit_speed ?? 0 } } export function useNodes(config: SiteConfig | null) { @@ -122,14 +51,41 @@ export function useNodes(config: SiteConfig | null) { const [pool, setPool] = useState(null) useEffect(() => { - if (!config?.site_tokens?.length) { - setLoading(false) - return - } + if (!config?.site_tokens?.length) { setLoading(false); return } const pool = new BackendPool(config.site_tokens) setPool(pool) const sourceUuids = new Map() + const tickDynamic = async () => { + const updates: Array<{ source: string; row: DynamicSummary }> = [] + await Promise.allSettled(pool.entries.map(async entry => { + const uuids = sourceUuids.get(entry.name) || [] + if (!uuids.length) return + try { + const rows = await dynamicSummaryMulti(entry.client, uuids, DYNAMIC_FIELDS) + for (const row of rows || []) updates.push({ source: entry.name, row }) + } catch {} + })) + if (!updates.length) return + + setLive(prev => { + const next = new Map(prev) + for (const { source, row } of updates) next.set(nodeId(source, row.uuid), row) + return next + }) + setHistory(prev => { + const next = new Map(prev) + for (const { source, row } of updates) { + const key = nodeId(source, row.uuid) + const arr = next.get(key) || [] + const sample = sampleFrom(row) + const dedup = arr.length && arr[arr.length - 1].t === sample.t ? arr : arr.concat(sample) + next.set(key, dedup.slice(-HISTORY_LIMIT)) + } + return next + }) + } + const bootstrap = async () => { const agentsRes = await pool.fanout(listAgentUuids) setErrors(prev => [...prev, ...agentsRes.errors]) @@ -138,118 +94,64 @@ export function useNodes(config: SiteConfig | null) { for (const { source, rows } of agentsRes.ok) { const uuids = rows ?? [] sourceUuids.set(source, uuids) - for (const uuid of uuids) seed.set(uuid, blankAgent(uuid, source)) + for (const uuid of uuids) seed.set(nodeId(source, uuid), blankAgent(uuid, source)) } setAgents(seed) - await Promise.all( - pool.entries.map(async entry => { - const uuids = sourceUuids.get(entry.name) || [] - if (!uuids.length) return - - const kvItems = uuids.flatMap(u => META_KEYS.map(k => ({ namespace: u, key: k }))) - const [meta, stat] = await Promise.allSettled([ - kvGetMulti(entry.client, kvItems), - staticDataMulti(entry.client, uuids, STATIC_FIELDS), - ]) - - setAgents(prev => { - const next = new Map(prev) - - if (meta.status === 'fulfilled' && meta.value) { - const grouped = new Map>() - for (const row of meta.value) { - if (!row || row.value == null) continue - let bucket = grouped.get(row.namespace) - if (!bucket) grouped.set(row.namespace, (bucket = {})) - bucket[row.key] = row.value - } - for (const uuid of uuids) { - const cur = next.get(uuid) ?? blankAgent(uuid, entry.name) - next.set(uuid, { ...cur, meta: parseMeta(grouped.get(uuid) ?? {}) }) - } + await Promise.all(pool.entries.map(async entry => { + const uuids = sourceUuids.get(entry.name) || [] + if (!uuids.length) return + const kvItems = uuids.flatMap(u => META_KEYS.map(k => ({ namespace: u, key: k }))) + const [meta, stat] = await Promise.allSettled([kvGetMulti(entry.client, kvItems), staticDataMulti(entry.client, uuids, STATIC_FIELDS)]) + + setAgents(prev => { + const next = new Map(prev) + if (meta.status === 'fulfilled' && meta.value) { + const grouped = new Map>() + for (const row of meta.value) { + if (!row || row.value == null) continue + let bucket = grouped.get(row.namespace) + if (!bucket) grouped.set(row.namespace, (bucket = {})) + bucket[row.key] = row.value } - - if (stat.status === 'fulfilled' && stat.value) { - for (const row of stat.value) { - if (!row.uuid) continue - const cur = next.get(row.uuid) ?? blankAgent(row.uuid, entry.name) - next.set(row.uuid, { ...cur, static: row }) - } + for (const uuid of uuids) { + const id = nodeId(entry.name, uuid) + const cur = next.get(id) ?? blankAgent(uuid, entry.name) + next.set(id, { ...cur, meta: parseMeta(grouped.get(uuid) ?? {}) }) + } + } + if (stat.status === 'fulfilled' && stat.value) { + for (const row of stat.value) { + if (!row.uuid) continue + const id = nodeId(entry.name, row.uuid) + const cur = next.get(id) ?? blankAgent(row.uuid, entry.name) + next.set(id, { ...cur, static: row }) } - return next - }) - }), - ) + } + return next + }) + })) await tickDynamic() setLoading(false) } - const tickDynamic = async () => { - const updates: DynamicSummary[] = [] - await Promise.allSettled( - pool.entries.map(async entry => { - const uuids = sourceUuids.get(entry.name) || [] - if (!uuids.length) return - try { - const rows = await dynamicSummaryMulti(entry.client, uuids, DYNAMIC_FIELDS) - for (const row of rows || []) updates.push(row) - } catch {} - }), - ) - if (!updates.length) return + bootstrap().catch((e: unknown) => { setErrors(prev => [...prev, { source: '*', error: e }]); setLoading(false) }) - setLive(prev => { - const next = new Map(prev) - for (const row of updates) next.set(row.uuid, row) - return next - }) - setHistory(prev => { - const next = new Map(prev) - for (const row of updates) { - const arr = next.get(row.uuid) || [] - const sample = sampleFrom(row) - const dedup = arr.length && arr[arr.length - 1].t === sample.t ? arr : arr.concat(sample) - next.set(row.uuid, dedup.slice(-HISTORY_LIMIT)) - } - return next - }) - } - - bootstrap().catch((e: unknown) => { - setErrors(prev => [...prev, { source: '*', error: e }]) - setLoading(false) - }) - - const onVisible = () => { - if (document.visibilityState === 'visible') tickDynamic() - } + const onVisible = () => { if (document.visibilityState === 'visible') tickDynamic() } document.addEventListener('visibilitychange', onVisible) - const dynTimer = setInterval(tickDynamic, DYN_INTERVAL_MS) const clockTimer = setInterval(() => setTick(t => t + 1), 5000) - return () => { - clearInterval(dynTimer) - clearInterval(clockTimer) - document.removeEventListener('visibilitychange', onVisible) - setPool(null) - pool.close() - } + return () => { clearInterval(dynTimer); clearInterval(clockTimer); document.removeEventListener('visibilitychange', onVisible); setPool(null); pool.close() } }, [config]) const nodes = useMemo(() => { const now = Date.now() const out = new Map() - for (const [uuid, a] of agents) { - const dyn = live.get(uuid) || null - out.set(uuid, { - ...a, - dynamic: dyn, - history: history.get(uuid) || [], - online: isOnline(dyn?.timestamp, now), - }) + for (const [id, a] of agents) { + const dyn = live.get(id) || null + out.set(id, { ...a, dynamic: dyn, history: history.get(id) || [], online: isOnline(dyn?.timestamp, now) }) } return out }, [agents, live, history, tick]) diff --git a/src/types.ts b/src/types.ts index df5702a..589f9c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,7 @@ export interface HistorySample { } export interface Node { + id: string uuid: string source: string online: boolean From f25039325e87835bf8dbe57e00dad1c37a0221b5 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 11:57:38 +0800 Subject: [PATCH 24/53] Refine cyber cards and fix TCP ping display --- package.json | 1 + src/components/NodeCard.tsx | 158 +++++++++++++++++++++++++----------- src/hooks/useNodeLatency.ts | 2 +- src/styles/global.css | 71 ++++++++-------- src/utils/latency.ts | 20 ++++- 5 files changed, 163 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index 1265598..85e20a5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "prebuild": "node scripts/build-config.mjs", "build": "vite build", "preview": "vite preview", + "deploy:cf": "npm run build && npx wrangler deploy", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 267be04..16097ee 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -1,4 +1,14 @@ -import { ArrowDown, ArrowUp, Clock, type LucideIcon } from 'lucide-react' +import { + ArrowDown, + ArrowUp, + Clock, + Cpu, + Gauge, + HardDrive, + MemoryStick, + Server, + type LucideIcon, +} from 'lucide-react' import { Badge } from './ui/badge' import { Card } from './ui/card' import { Progress } from './ui/progress' @@ -16,6 +26,7 @@ export function NodeCard({ node }: { node: Node }) { const logo = distroLogo(node) const virt = virtLabel(node) const cpu = cpuLabel(node) + const updated = relativeAge(u.ts) const Wrapper = node.online ? 'a' : 'div' const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.id)}` } : {} @@ -23,82 +34,135 @@ export function NodeCard({ node }: { node: Node }) { -
-
- - {logo && } - - {displayName(node)} - - -
- {(os || virt) &&
{[os, virt].filter(Boolean).join(' · ')}
} -
- - - -
-
-
- {bytes(u.netIn || 0)}/s - {bytes(u.netOut || 0)}/s +
+ +
+
+
+ {logo ? ( + + ) : ( + + )} + + + +
+ +
+
+ + {displayName(node)} + + +
+
+ + {node.online ? 'Online' : 'Offline'} + {virt && / {virt}} +
+
+
+ + {(os || cpu) && ( +
+ {os && {os}} + {cpu && {cpu}} +
+ )} + +
+ + +
-
+ +
+ {bytes(u.netIn || 0)}/s + {bytes(u.netOut || 0)}/s +
+ +
{uptime(u.uptime)} - {relativeAge(u.ts)} + {updated}
+ + {tags.length > 0 && ( +
+ {tags.slice(0, 4).map(t => ( + + {t} + + ))} + {tags.length > 4 && ( + + +{tags.length - 4} + + )} +
+ )}
- {tags.length > 0 && ( -
- {tags.map(t => ( - - {t} - - ))} -
- )} ) } + +function Info({ icon: Icon, children }: { icon: LucideIcon; children: ReactNode }) { + return ( + + + {children} + + ) +} + function Metric({ + icon: Icon, label, value, - sub, - subTitle, }: { + icon: LucideIcon label: string value?: number | null - sub?: ReactNode - subTitle?: string }) { const numericValue = Number.isFinite(value) ? (value as number) : undefined const percent = pct(numericValue) return ( -
-
- {label} - {sub ? ( - - {sub} - - ) : null} - {percent} +
+
+ + + {label} + + {percent}
) } + +function StatBox({ icon: Icon, label, children }: { icon: LucideIcon; label: string; children: ReactNode }) { + return ( +
+
+ + {label} +
+
{children}
+
+ ) +} + function Stat({ icon: Icon, children }: { icon: LucideIcon; children: ReactNode }) { return ( diff --git a/src/hooks/useNodeLatency.ts b/src/hooks/useNodeLatency.ts index 0746f44..4f6525f 100644 --- a/src/hooks/useNodeLatency.ts +++ b/src/hooks/useNodeLatency.ts @@ -9,7 +9,7 @@ const QUERY_TIMEOUT_MS = 20_000 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { return (rows ?? []) - .filter(r => r.cron_source && r.cron_source !== '未知') + .filter(r => r && r.timestamp) .sort((a, b) => a.timestamp - b.timestamp) } diff --git a/src/styles/global.css b/src/styles/global.css index da81385..dd015e7 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -132,15 +132,15 @@ position: relative; overflow: hidden; background: - linear-gradient(152deg, rgba(11, 23, 54, 0.92), rgba(34, 22, 73, 0.86)), - radial-gradient(circle at top right, rgba(0, 224, 255, 0.2), transparent 48%), - radial-gradient(circle at 12% 90%, rgba(244, 114, 182, 0.2), transparent 42%); - border-color: rgba(125, 211, 252, 0.35); + linear-gradient(145deg, rgba(8, 18, 40, 0.96), rgba(15, 23, 42, 0.94) 46%, rgba(27, 25, 53, 0.92)), + linear-gradient(90deg, rgba(34, 211, 238, 0.12), transparent 28%, rgba(52, 211, 153, 0.08) 70%, rgba(244, 63, 94, 0.10)); + border-color: rgba(103, 232, 249, 0.26); box-shadow: - inset 0 1px 0 rgba(187, 247, 255, 0.08), - 0 20px 36px -20px rgba(30, 41, 59, 0.72), - 0 0 0 1px rgba(56, 189, 248, 0.25), - 0 0 32px -18px rgba(99, 102, 241, 0.65); + inset 0 1px 0 rgba(236, 254, 255, 0.08), + inset 0 -1px 0 rgba(34, 211, 238, 0.08), + 0 18px 36px -22px rgba(2, 6, 23, 0.9), + 0 0 0 1px rgba(34, 211, 238, 0.14), + 0 0 32px -20px rgba(45, 212, 191, 0.85); isolation: isolate; } @@ -161,11 +161,11 @@ inset: -1px; border-radius: inherit; padding: 1px; - background: linear-gradient(130deg, rgba(56, 189, 248, 0.5), rgba(59, 130, 246, 0), rgba(34, 197, 94, 0.45)); + background: linear-gradient(120deg, rgba(103, 232, 249, 0.65), rgba(56, 189, 248, 0.05) 34%, rgba(52, 211, 153, 0.48) 72%, rgba(251, 113, 133, 0.45)); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; - opacity: 0.42; + opacity: 0.52; pointer-events: none; } @@ -173,7 +173,9 @@ content: ''; position: absolute; inset: 0; - background: linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.24) 44%, rgba(244, 114, 182, 0.22) 58%, transparent 78%); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 28%), + linear-gradient(110deg, transparent 24%, rgba(125, 211, 252, 0.22) 46%, rgba(52, 211, 153, 0.18) 58%, transparent 76%); transform: translateX(-130%); transition: transform 0.9s ease; pointer-events: none; @@ -188,13 +190,13 @@ } .cyber-card-active::before { - opacity: 0.56; + opacity: 0.72; } .cyber-card-active::after { background: - linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.26) 44%, rgba(244, 114, 182, 0.24) 58%, transparent 78%), - linear-gradient(165deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.02) 46%, transparent 72%); + linear-gradient(110deg, transparent 20%, rgba(125, 211, 252, 0.28) 44%, rgba(52, 211, 153, 0.22) 58%, transparent 78%), + linear-gradient(165deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.02) 46%, transparent 72%); } .cyber-card-offline::before, @@ -203,30 +205,29 @@ } .cyber-card-active { animation: none; } -.cyber-orb { + +.cyber-grid { position: absolute; - width: 8.25rem; - height: 8.25rem; - top: -2.6rem; - right: -1.2rem; - border-radius: 9999px; - background: radial-gradient(circle, rgba(56, 189, 248, 0.42) 0%, rgba(56, 189, 248, 0.14) 35%, transparent 72%); - filter: blur(1px); - opacity: 0.75; - animation: orb-float 7s ease-in-out infinite; + inset: 0; + background-image: + linear-gradient(to right, rgba(125, 211, 252, 0.075) 1px, transparent 1px), + linear-gradient(to bottom, rgba(125, 211, 252, 0.055) 1px, transparent 1px), + linear-gradient(135deg, transparent 0 47%, rgba(52, 211, 153, 0.13) 48% 49%, transparent 50% 100%); + background-size: 18px 18px, 18px 18px, 96px 96px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.54), transparent 78%); + opacity: 0.82; + animation: grid-drift 14s linear infinite; pointer-events: none; } -.cyber-grid { +.cyber-scan { position: absolute; inset: 0; - background-image: - linear-gradient(to right, rgba(125, 211, 252, 0.08) 1px, transparent 1px), - linear-gradient(to bottom, rgba(125, 211, 252, 0.07) 1px, transparent 1px); - background-size: 20px 20px; - mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.38), transparent 72%); - opacity: 0.7; - animation: grid-drift 12s linear infinite; + background: + repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 5px), + linear-gradient(90deg, transparent, rgba(34, 211, 238, 0.12), transparent); + opacity: 0.18; + mix-blend-mode: screen; pointer-events: none; } @@ -267,12 +268,6 @@ } } -@keyframes orb-float { - 0%, - 100% { transform: translate3d(0, 0, 0) scale(1); } - 50% { transform: translate3d(-10px, 8px, 0) scale(1.08); } -} - @keyframes grid-drift { 0% { transform: translate3d(0, 0, 0); } 100% { transform: translate3d(20px, 20px, 0); } diff --git a/src/utils/latency.ts b/src/utils/latency.ts index 71ebcf3..7a8083f 100644 --- a/src/utils/latency.ts +++ b/src/utils/latency.ts @@ -21,6 +21,20 @@ function normalizeTs(ts: number) { return ts < 1_000_000_000_000 ? ts * 1000 : ts } +export function latencySeriesName(row: TaskQueryResult) { + const source = typeof row.cron_source === 'string' ? row.cron_source.trim() : '' + if (source && source !== '未知') return source + + const event = row.task_event_type + if (event && typeof event === 'object') { + const payload = event as Record + const target = payload.tcp_ping ?? payload.ping + if (typeof target === 'string' && target.trim()) return target.trim() + } + + return row.task_id ? `任务 #${row.task_id}` : '未知来源' +} + function pickValue(row: TaskQueryResult, type: LatencyType): number | null { if (!row.success) return null const payload = row.task_event_result @@ -62,7 +76,7 @@ function pickValue(row: TaskQueryResult, type: LatencyType): number | null { function seriesNames(rows: TaskQueryResult[]) { const set = new Set() - for (const r of rows) set.add(r.cron_source || '未知') + for (const r of rows) set.add(latencySeriesName(r)) return [...set].sort((a, b) => a.localeCompare(b)) } @@ -101,7 +115,7 @@ export function buildLatencyChart(rows: TaskQueryResult[], type: LatencyType) { for (const n of names) pt[n] = null byTs.set(t, pt) } - pt[r.cron_source || '未知'] = pickValue(r, type) + pt[latencySeriesName(r)] = pickValue(r, type) } const data = [...byTs.values()].sort((a, b) => a.t - b.t) @@ -119,7 +133,7 @@ export interface LatencyStats { export function computeLatencyStats(rows: TaskQueryResult[], type: LatencyType): LatencyStats[] { const stats = seriesNames(rows).map(name => { - const list = rows.filter(r => (r.cron_source || '未知') === name) + const list = rows.filter(r => latencySeriesName(r) === name) const vals: number[] = [] for (const r of list) { const v = pickValue(r, type) From f06938c9ccaae02ed35f15ae7fbb21de16a80d05 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 12:22:57 +0800 Subject: [PATCH 25/53] Fix detail latency data display --- src/components/NodeDetail.tsx | 27 ++++++++---- src/hooks/useNodeLatency.ts | 78 +++++++++++++++++++++++++++++++---- src/utils/latency.ts | 24 +++++++++-- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 6622139..0a628e8 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -73,7 +73,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { return () => el.removeEventListener('scroll', onScroll) }, [node]) - const { pingData, tcpData, loading: latencyLoading } = useNodeLatency( + const { pingData, tcpData, statusData, loading: latencyLoading } = useNodeLatency( pool, node?.source ?? null, node?.uuid ?? null, @@ -203,7 +203,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) {
- +
@@ -264,7 +264,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { ) } -type HourState = 'online' | 'partial_offline' | 'fully_offline' +type HourState = 'online' | 'partial_offline' | 'fully_offline' | 'unknown' function buildHourState(rows: TaskQueryResult[]): HourState[] { const now = Date.now() @@ -277,7 +277,7 @@ function buildHourState(rows: TaskQueryResult[]): HourState[] { return ts >= start && ts < end }) if (!bucket.length) { - states.push('fully_offline') + states.push('unknown') continue } const fail = bucket.filter(r => !r.success).length @@ -288,16 +288,27 @@ function buildHourState(rows: TaskQueryResult[]): HourState[] { return states } -function OnlinePanel({ rows }: { rows: TaskQueryResult[] }) { +function OnlinePanel({ + rows, + loading, + nodeOnline, +}: { + rows: TaskQueryResult[] + loading: boolean + nodeOnline: boolean +}) { const states = useMemo(() => buildHourState(rows), [rows]) + const knownHours = states.filter(s => s !== 'unknown').length const onlineHours = states.filter(s => s === 'online').length - const onlineRatio = Math.round((onlineHours / 24) * 100) + const onlineRatio = knownHours ? Math.round((onlineHours / knownHours) * 100) : null return (
在线状态 · 24小时 - {onlineRatio}% + + {onlineRatio == null ? (loading ? '同步中' : nodeOnline ? '在线' : '未知') : `${onlineRatio}%`} +
{states.map((state, idx) => ( @@ -309,6 +320,7 @@ function OnlinePanel({ rows }: { rows: TaskQueryResult[] }) { state === 'online' && 'bg-emerald-400/85', state === 'partial_offline' && 'bg-yellow-400/85', state === 'fully_offline' && 'bg-red-500/90', + state === 'unknown' && 'bg-slate-600/35', )} /> ))} @@ -317,6 +329,7 @@ function OnlinePanel({ rows }: { rows: TaskQueryResult[] }) { 在线 有离线 整小时离线 + 无记录
) diff --git a/src/hooks/useNodeLatency.ts b/src/hooks/useNodeLatency.ts index 4f6525f..d40d1f2 100644 --- a/src/hooks/useNodeLatency.ts +++ b/src/hooks/useNodeLatency.ts @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react' import { taskQuery } from '../api/methods' +import { latencyTaskType, latencyValue } from '../utils/latency' import type { BackendPool } from '../api/pool' -import type { TaskQueryResult } from '../types' +import type { LatencyType, TaskQueryResult } from '../types' -const WINDOW_MS = 60 * 60 * 1000 +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS const REFRESH_MS = 10_000 const QUERY_TIMEOUT_MS = 20_000 +const FALLBACK_LIMIT = 500 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { return (rows ?? []) @@ -13,6 +16,25 @@ function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { .sort((a, b) => a.timestamp - b.timestamp) } +function matchesLatencyType(row: TaskQueryResult, type: LatencyType) { + const taskType = latencyTaskType(row) + if (taskType) return taskType === type + return latencyValue(row, type) != null +} + +function inWindow(row: TaskQueryResult, from: number, to: number) { + const ts = row.timestamp < 1_000_000_000_000 ? row.timestamp * 1000 : row.timestamp + return ts >= from && ts <= to +} + +function mergeRows(...groups: TaskQueryResult[][]) { + const map = new Map() + for (const row of groups.flat()) { + map.set(`${row.task_id}:${row.timestamp}:${row.uuid}`, row) + } + return clean([...map.values()]) +} + export function useNodeLatency( pool: BackendPool | null, source: string | null, @@ -20,11 +42,13 @@ export function useNodeLatency( ) { const [pingData, setPingData] = useState([]) const [tcpData, setTcpData] = useState([]) + const [statusData, setStatusData] = useState([]) const [loading, setLoading] = useState(false) useEffect(() => { setPingData([]) setTcpData([]) + setStatusData([]) if (!pool || !source || !uuid) return const entry = pool.entries.find(e => e.name === source) @@ -34,25 +58,61 @@ export function useNodeLatency( const fetchOnce = async () => { const now = Date.now() - const window: [number, number] = [now - WINDOW_MS, now] + const hourWindow: [number, number] = [now - HOUR_MS, now] + const dayWindow: [number, number] = [now - DAY_MS, now] setLoading(true) - const [ping, tcp] = await Promise.allSettled([ + const [pingHour, tcpHour, pingDay, tcpDay, fallback] = await Promise.allSettled([ taskQuery( entry.client, - [{ uuid }, { timestamp_from_to: window }, { type: 'ping' }], + [{ uuid }, { timestamp_from_to: hourWindow }, { type: 'ping' }], QUERY_TIMEOUT_MS, ), taskQuery( entry.client, - [{ uuid }, { timestamp_from_to: window }, { type: 'tcp_ping' }], + [{ uuid }, { timestamp_from_to: hourWindow }, { type: 'tcp_ping' }], + QUERY_TIMEOUT_MS, + ), + taskQuery( + entry.client, + [{ uuid }, { timestamp_from_to: dayWindow }, { type: 'ping' }], + QUERY_TIMEOUT_MS, + ), + taskQuery( + entry.client, + [{ uuid }, { timestamp_from_to: dayWindow }, { type: 'tcp_ping' }], + QUERY_TIMEOUT_MS, + ), + taskQuery( + entry.client, + [{ uuid }, { limit: FALLBACK_LIMIT }], QUERY_TIMEOUT_MS, ), ]) if (cancelled) return - if (ping.status === 'fulfilled') setPingData(clean(ping.value)) - if (tcp.status === 'fulfilled') setTcpData(clean(tcp.value)) + const fallbackRows = + fallback.status === 'fulfilled' + ? clean(fallback.value).filter(r => inWindow(r, dayWindow[0], dayWindow[1])) + : [] + + const fallbackPing = fallbackRows.filter(r => matchesLatencyType(r, 'ping')) + const fallbackTcp = fallbackRows.filter(r => matchesLatencyType(r, 'tcp_ping')) + + const nextPingHour = mergeRows( + pingHour.status === 'fulfilled' ? pingHour.value : [], + fallbackPing.filter(r => inWindow(r, hourWindow[0], hourWindow[1])), + ) + const nextTcpHour = mergeRows( + tcpHour.status === 'fulfilled' ? tcpHour.value : [], + fallbackTcp.filter(r => inWindow(r, hourWindow[0], hourWindow[1])), + ) + const nextPingDay = mergeRows(pingDay.status === 'fulfilled' ? pingDay.value : [], fallbackPing) + const nextTcpDay = mergeRows(tcpDay.status === 'fulfilled' ? tcpDay.value : [], fallbackTcp) + + setPingData(nextPingHour) + setTcpData(nextTcpHour) + setStatusData(mergeRows(nextPingDay, nextTcpDay)) setLoading(false) } @@ -64,5 +124,5 @@ export function useNodeLatency( } }, [pool, source, uuid]) - return { pingData, tcpData, loading } + return { pingData, tcpData, statusData, loading } } diff --git a/src/utils/latency.ts b/src/utils/latency.ts index 7a8083f..35d5272 100644 --- a/src/utils/latency.ts +++ b/src/utils/latency.ts @@ -35,7 +35,25 @@ export function latencySeriesName(row: TaskQueryResult) { return row.task_id ? `任务 #${row.task_id}` : '未知来源' } -function pickValue(row: TaskQueryResult, type: LatencyType): number | null { +export function latencyTaskType(row: TaskQueryResult): LatencyType | null { + const event = row.task_event_type + if (event && typeof event === 'object') { + const payload = event as Record + if ('tcp_ping' in payload || 'tcpPing' in payload) return 'tcp_ping' + if ('ping' in payload || 'icmp_ping' in payload || 'icmpPing' in payload) return 'ping' + } + + const payload = row.task_event_result + if (payload && typeof payload === 'object') { + const result = payload as Record + if ('tcp_ping' in result || 'tcpPing' in result) return 'tcp_ping' + if ('ping' in result || 'icmp_ping' in result || 'icmpPing' in result) return 'ping' + } + + return null +} + +export function latencyValue(row: TaskQueryResult, type: LatencyType): number | null { if (!row.success) return null const payload = row.task_event_result if (!payload) return null @@ -115,7 +133,7 @@ export function buildLatencyChart(rows: TaskQueryResult[], type: LatencyType) { for (const n of names) pt[n] = null byTs.set(t, pt) } - pt[latencySeriesName(r)] = pickValue(r, type) + pt[latencySeriesName(r)] = latencyValue(r, type) } const data = [...byTs.values()].sort((a, b) => a.t - b.t) @@ -136,7 +154,7 @@ export function computeLatencyStats(rows: TaskQueryResult[], type: LatencyType): const list = rows.filter(r => latencySeriesName(r) === name) const vals: number[] = [] for (const r of list) { - const v = pickValue(r, type) + const v = latencyValue(r, type) if (v != null) vals.push(v) } From 8fac0b1188e4f3cfd58a9107b5f32dabe77f65d8 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 12:29:41 +0800 Subject: [PATCH 26/53] Add live latency probe fallback --- src/api/methods.ts | 13 ++++- src/api/pool.ts | 2 + src/components/NodeCard.tsx | 11 +++- src/components/NodeDetail.tsx | 12 ++-- src/hooks/useNodeLatency.ts | 103 +++++++++++++++++++++++++++++----- src/types.ts | 10 ++++ src/utils/status.ts | 2 +- 7 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/api/methods.ts b/src/api/methods.ts index fdcb6d6..e32a2d4 100644 --- a/src/api/methods.ts +++ b/src/api/methods.ts @@ -1,5 +1,5 @@ import type { RpcClient } from './client' -import type { DynamicSummary, StaticData, TaskQueryCondition, TaskQueryResult } from '../types' +import type { DynamicSummary, StaticData, TaskCreateBlockingResult, TaskQueryCondition, TaskQueryResult } from '../types' export const listAgentUuids = (c: RpcClient) => c.call<{ uuids?: string[] }>('nodeget-server_list_all_agent_uuid', {}).then(r => r?.uuids || []) @@ -20,3 +20,14 @@ export const taskQuery = ( conditions: TaskQueryCondition[], timeoutMs?: number, ) => c.call('task_query', { task_data_query: { condition: conditions } }, timeoutMs) + +export const taskCreateBlocking = ( + c: RpcClient, + uuid: string, + taskType: Record, + timeoutMs = 8000, +) => c.call( + 'task_create_task_blocking', + { target_uuid: uuid, task_type: taskType, timeout_ms: timeoutMs }, + timeoutMs + 1000, +) diff --git a/src/api/pool.ts b/src/api/pool.ts index 6858981..89e39ff 100644 --- a/src/api/pool.ts +++ b/src/api/pool.ts @@ -8,6 +8,7 @@ export interface BackendToken { export interface PoolEntry { name: string + backend_url: string client: RpcClient } @@ -17,6 +18,7 @@ export class BackendPool { constructor(tokens: BackendToken[]) { this.entries = tokens.map(t => ({ name: t.name, + backend_url: t.backend_url, client: new RpcClient(t.backend_url, t.token, t.name), })) } diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 16097ee..039df4c 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -27,6 +27,7 @@ export function NodeCard({ node }: { node: Node }) { const virt = virtLabel(node) const cpu = cpuLabel(node) const updated = relativeAge(u.ts) + const updateState = node.online ? 'ONLINE' : 'OFFLINE' const Wrapper = node.online ? 'a' : 'div' const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.id)}` } : {} @@ -90,7 +91,15 @@ export function NodeCard({ node }: { node: Node }) {
{uptime(u.uptime)} - {updated} + + {updateState} +
{tags.length > 0 && ( diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 0a628e8..f10e160 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -73,7 +73,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { return () => el.removeEventListener('scroll', onScroll) }, [node]) - const { pingData, tcpData, statusData, loading: latencyLoading } = useNodeLatency( + const { pingData, tcpData, statusData, loading: latencyLoading, error: latencyError } = useNodeLatency( pool, node?.source ?? null, node?.uuid ?? null, @@ -216,8 +216,9 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { rows={tcpData} type="tcp_ping" loading={latencyLoading} + error={latencyError} /> - +
@@ -475,11 +476,12 @@ interface LatencyBlockProps { rows: TaskQueryResult[] type: LatencyType loading: boolean + error?: string | null } const ms = (v: number) => `${v.toFixed(1)} ms` -function LatencyBlock({ title, rows, type, loading }: LatencyBlockProps) { +function LatencyBlock({ title, rows, type, loading, error }: LatencyBlockProps) { const { data, series } = useMemo(() => buildLatencyChart(rows, type), [rows, type]) const stats = useMemo(() => computeLatencyStats(rows, type), [rows, type]) const [hidden, setHidden] = useState>(() => new Set()) @@ -500,7 +502,7 @@ function LatencyBlock({ title, rows, type, loading }: LatencyBlockProps) {
{empty && (
- {loading ? '加载中…' : `暂无 ${type} 数据`} + {loading ? '加载中…' : error ? `暂无 ${type} 数据 · ${error}` : `暂无 ${type} 数据`}
)} {!empty && ( @@ -534,7 +536,7 @@ function LatencyBlock({ title, rows, type, loading }: LatencyBlockProps) { dataKey={s.name} stroke={s.color} strokeWidth={1.5} - dot={false} + dot={data.length <= 2} connectNulls isAnimationActive={false} /> diff --git a/src/hooks/useNodeLatency.ts b/src/hooks/useNodeLatency.ts index d40d1f2..e281356 100644 --- a/src/hooks/useNodeLatency.ts +++ b/src/hooks/useNodeLatency.ts @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { taskQuery } from '../api/methods' +import { useEffect, useRef, useState } from 'react' +import { taskCreateBlocking, taskQuery } from '../api/methods' import { latencyTaskType, latencyValue } from '../utils/latency' import type { BackendPool } from '../api/pool' import type { LatencyType, TaskQueryResult } from '../types' @@ -9,6 +9,8 @@ const DAY_MS = 24 * HOUR_MS const REFRESH_MS = 10_000 const QUERY_TIMEOUT_MS = 20_000 const FALLBACK_LIMIT = 500 +const LIVE_PROBE_INTERVAL_MS = 60_000 +const LIVE_PROBE_TIMEOUT_MS = 8_000 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { return (rows ?? []) @@ -35,6 +37,36 @@ function mergeRows(...groups: TaskQueryResult[][]) { return clean([...map.values()]) } +function probeTarget(backendUrl: string) { + try { + const u = new URL(backendUrl) + const host = u.hostname + if (!host) return null + const port = u.port || (u.protocol === 'wss:' || u.protocol === 'https:' ? '443' : '80') + return { ping: host, tcp: `${host}:${port}` } + } catch { + return null + } +} + +function probeRow( + uuid: string, + type: LatencyType, + target: string, + result: Awaited>, +): TaskQueryResult { + return { + task_id: result.task_id, + uuid, + timestamp: result.timestamp, + success: result.success, + error_message: result.error_message, + cron_source: '实时探测', + task_event_type: { [type]: target }, + task_event_result: result.task_event_result, + } +} + export function useNodeLatency( pool: BackendPool | null, source: string | null, @@ -43,12 +75,16 @@ export function useNodeLatency( const [pingData, setPingData] = useState([]) const [tcpData, setTcpData] = useState([]) const [statusData, setStatusData] = useState([]) + const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const lastProbeAt = useRef(0) useEffect(() => { setPingData([]) setTcpData([]) setStatusData([]) + setError(null) + lastProbeAt.current = 0 if (!pool || !source || !uuid) return const entry = pool.entries.find(e => e.name === source) @@ -62,7 +98,7 @@ export function useNodeLatency( const dayWindow: [number, number] = [now - DAY_MS, now] setLoading(true) - const [pingHour, tcpHour, pingDay, tcpDay, fallback] = await Promise.allSettled([ + const [pingHour, tcpHour, pingDay, tcpDay, pingRecent, tcpRecent] = await Promise.allSettled([ taskQuery( entry.client, [{ uuid }, { timestamp_from_to: hourWindow }, { type: 'ping' }], @@ -85,30 +121,71 @@ export function useNodeLatency( ), taskQuery( entry.client, - [{ uuid }, { limit: FALLBACK_LIMIT }], + [{ uuid }, { type: 'ping' }, { limit: FALLBACK_LIMIT }], + QUERY_TIMEOUT_MS, + ), + taskQuery( + entry.client, + [{ uuid }, { type: 'tcp_ping' }, { limit: FALLBACK_LIMIT }], QUERY_TIMEOUT_MS, ), ]) if (cancelled) return - const fallbackRows = - fallback.status === 'fulfilled' - ? clean(fallback.value).filter(r => inWindow(r, dayWindow[0], dayWindow[1])) - : [] + const queryErrors = [pingHour, tcpHour, pingDay, tcpDay, pingRecent, tcpRecent] + .filter((r): r is PromiseRejectedResult => r.status === 'rejected') + .map(r => r.reason instanceof Error ? r.reason.message : String(r.reason)) + setError(queryErrors[0] ?? null) + + const fallbackRows = mergeRows( + pingRecent.status === 'fulfilled' ? pingRecent.value : [], + tcpRecent.status === 'fulfilled' ? tcpRecent.value : [], + ).filter(r => inWindow(r, dayWindow[0], dayWindow[1])) const fallbackPing = fallbackRows.filter(r => matchesLatencyType(r, 'ping')) const fallbackTcp = fallbackRows.filter(r => matchesLatencyType(r, 'tcp_ping')) - const nextPingHour = mergeRows( + let nextPingHour = mergeRows( pingHour.status === 'fulfilled' ? pingHour.value : [], fallbackPing.filter(r => inWindow(r, hourWindow[0], hourWindow[1])), ) - const nextTcpHour = mergeRows( + let nextTcpHour = mergeRows( tcpHour.status === 'fulfilled' ? tcpHour.value : [], fallbackTcp.filter(r => inWindow(r, hourWindow[0], hourWindow[1])), ) - const nextPingDay = mergeRows(pingDay.status === 'fulfilled' ? pingDay.value : [], fallbackPing) - const nextTcpDay = mergeRows(tcpDay.status === 'fulfilled' ? tcpDay.value : [], fallbackTcp) + let nextPingDay = mergeRows(pingDay.status === 'fulfilled' ? pingDay.value : [], fallbackPing) + let nextTcpDay = mergeRows(tcpDay.status === 'fulfilled' ? tcpDay.value : [], fallbackTcp) + + const shouldProbe = (!nextPingHour.length || !nextTcpHour.length) && now - lastProbeAt.current > LIVE_PROBE_INTERVAL_MS + const target = shouldProbe ? probeTarget(entry.backend_url) : null + if (target) { + lastProbeAt.current = now + const [pingProbe, tcpProbe] = await Promise.allSettled([ + !nextPingHour.length + ? taskCreateBlocking(entry.client, uuid, { ping: target.ping }, LIVE_PROBE_TIMEOUT_MS) + : Promise.resolve(null), + !nextTcpHour.length + ? taskCreateBlocking(entry.client, uuid, { tcp_ping: target.tcp }, LIVE_PROBE_TIMEOUT_MS) + : Promise.resolve(null), + ]) + + if (cancelled) return + const probeErrors = [pingProbe, tcpProbe] + .filter((r): r is PromiseRejectedResult => r.status === 'rejected') + .map(r => r.reason instanceof Error ? r.reason.message : String(r.reason)) + if (probeErrors.length) setError(probeErrors[0]) + + if (pingProbe.status === 'fulfilled' && pingProbe.value) { + const row = probeRow(uuid, 'ping', target.ping, pingProbe.value) + nextPingHour = mergeRows(nextPingHour, [row]) + nextPingDay = mergeRows(nextPingDay, [row]) + } + if (tcpProbe.status === 'fulfilled' && tcpProbe.value) { + const row = probeRow(uuid, 'tcp_ping', target.tcp, tcpProbe.value) + nextTcpHour = mergeRows(nextTcpHour, [row]) + nextTcpDay = mergeRows(nextTcpDay, [row]) + } + } setPingData(nextPingHour) setTcpData(nextTcpHour) @@ -124,5 +201,5 @@ export function useNodeLatency( } }, [pool, source, uuid]) - return { pingData, tcpData, statusData, loading } + return { pingData, tcpData, statusData, loading, error } } diff --git a/src/types.ts b/src/types.ts index 589f9c2..ca3d1dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,6 +108,16 @@ export interface TaskQueryResult { task_event_result: Record | null } +export interface TaskCreateBlockingResult { + task_id: number + agent_uuid: string + task_token?: string + timestamp: number + success: boolean + error_message?: string | null + task_event_result: Record | null +} + export interface TaskQueryCondition { task_id?: number uuid?: string diff --git a/src/utils/status.ts b/src/utils/status.ts index e3b7891..4293ecf 100644 --- a/src/utils/status.ts +++ b/src/utils/status.ts @@ -1,4 +1,4 @@ -export const OFFLINE_AFTER_MS = 30_000 +export const OFFLINE_AFTER_MS = 5 * 60_000 export function isOnline(timestamp?: number | null, now = Date.now()) { return !!timestamp && now - timestamp < OFFLINE_AFTER_MS From fcc07b1434742dbfb7558aa5cff36687ceaf2c7f Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 12:34:56 +0800 Subject: [PATCH 27/53] Use supported latency permission mode --- src/api/methods.ts | 13 +---- src/api/pool.ts | 2 - src/components/NodeDetail.tsx | 53 ++++++++++++++---- src/hooks/useNodeLatency.ts | 101 ++++++++-------------------------- src/types.ts | 10 ---- 5 files changed, 65 insertions(+), 114 deletions(-) diff --git a/src/api/methods.ts b/src/api/methods.ts index e32a2d4..fdcb6d6 100644 --- a/src/api/methods.ts +++ b/src/api/methods.ts @@ -1,5 +1,5 @@ import type { RpcClient } from './client' -import type { DynamicSummary, StaticData, TaskCreateBlockingResult, TaskQueryCondition, TaskQueryResult } from '../types' +import type { DynamicSummary, StaticData, TaskQueryCondition, TaskQueryResult } from '../types' export const listAgentUuids = (c: RpcClient) => c.call<{ uuids?: string[] }>('nodeget-server_list_all_agent_uuid', {}).then(r => r?.uuids || []) @@ -20,14 +20,3 @@ export const taskQuery = ( conditions: TaskQueryCondition[], timeoutMs?: number, ) => c.call('task_query', { task_data_query: { condition: conditions } }, timeoutMs) - -export const taskCreateBlocking = ( - c: RpcClient, - uuid: string, - taskType: Record, - timeoutMs = 8000, -) => c.call( - 'task_create_task_blocking', - { target_uuid: uuid, task_type: taskType, timeout_ms: timeoutMs }, - timeoutMs + 1000, -) diff --git a/src/api/pool.ts b/src/api/pool.ts index 89e39ff..6858981 100644 --- a/src/api/pool.ts +++ b/src/api/pool.ts @@ -8,7 +8,6 @@ export interface BackendToken { export interface PoolEntry { name: string - backend_url: string client: RpcClient } @@ -18,7 +17,6 @@ export class BackendPool { constructor(tokens: BackendToken[]) { this.entries = tokens.map(t => ({ name: t.name, - backend_url: t.backend_url, client: new RpcClient(t.backend_url, t.token, t.name), })) } diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index f10e160..b4db207 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -73,7 +73,15 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { return () => el.removeEventListener('scroll', onScroll) }, [node]) - const { pingData, tcpData, statusData, loading: latencyLoading, error: latencyError } = useNodeLatency( + const { + pingData, + tcpData, + statusData, + loading: latencyLoading, + pingError, + tcpError, + taskReadable, + } = useNodeLatency( pool, node?.source ?? null, node?.uuid ?? null, @@ -206,19 +214,25 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) {
- +
- - + {taskReadable ? ( + <> + + + + ) : ( + + )}
@@ -336,7 +350,7 @@ function OnlinePanel({ ) } -function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) { +function TcpMiniPanel({ rows, taskReadable }: { rows: TaskQueryResult[]; taskReadable: boolean }) { const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 5), [rows]) return (
@@ -358,11 +372,26 @@ function TcpMiniPanel({ rows }: { rows: TaskQueryResult[] }) {
))} - {stats.length === 0 &&
暂无 TCP ping 数据
} + {stats.length === 0 && ( +
+ {taskReadable ? '暂无 TCP ping 数据' : '当前 Token 未开放任务探测数据'} +
+ )}
) } +function TaskUnsupportedPanel() { + return ( +
+
+
当前 Token 未开放 Task Read 权限,无法读取 Ping / TCP Ping 任务历史。
+
页面已使用动态监控数据判断在线状态;如需延迟曲线,请给前端 Token 增加 Task Read: ping / tcp_ping。
+
+
+ ) +} + function Section({ title, children }: { title: string; children: ReactNode }) { return ( diff --git a/src/hooks/useNodeLatency.ts b/src/hooks/useNodeLatency.ts index e281356..babee36 100644 --- a/src/hooks/useNodeLatency.ts +++ b/src/hooks/useNodeLatency.ts @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { taskCreateBlocking, taskQuery } from '../api/methods' +import { useEffect, useState } from 'react' +import { taskQuery } from '../api/methods' import { latencyTaskType, latencyValue } from '../utils/latency' import type { BackendPool } from '../api/pool' import type { LatencyType, TaskQueryResult } from '../types' @@ -9,8 +9,6 @@ const DAY_MS = 24 * HOUR_MS const REFRESH_MS = 10_000 const QUERY_TIMEOUT_MS = 20_000 const FALLBACK_LIMIT = 500 -const LIVE_PROBE_INTERVAL_MS = 60_000 -const LIVE_PROBE_TIMEOUT_MS = 8_000 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { return (rows ?? []) @@ -37,36 +35,6 @@ function mergeRows(...groups: TaskQueryResult[][]) { return clean([...map.values()]) } -function probeTarget(backendUrl: string) { - try { - const u = new URL(backendUrl) - const host = u.hostname - if (!host) return null - const port = u.port || (u.protocol === 'wss:' || u.protocol === 'https:' ? '443' : '80') - return { ping: host, tcp: `${host}:${port}` } - } catch { - return null - } -} - -function probeRow( - uuid: string, - type: LatencyType, - target: string, - result: Awaited>, -): TaskQueryResult { - return { - task_id: result.task_id, - uuid, - timestamp: result.timestamp, - success: result.success, - error_message: result.error_message, - cron_source: '实时探测', - task_event_type: { [type]: target }, - task_event_result: result.task_event_result, - } -} - export function useNodeLatency( pool: BackendPool | null, source: string | null, @@ -75,16 +43,18 @@ export function useNodeLatency( const [pingData, setPingData] = useState([]) const [tcpData, setTcpData] = useState([]) const [statusData, setStatusData] = useState([]) - const [error, setError] = useState(null) + const [pingError, setPingError] = useState(null) + const [tcpError, setTcpError] = useState(null) + const [taskReadable, setTaskReadable] = useState(true) const [loading, setLoading] = useState(false) - const lastProbeAt = useRef(0) useEffect(() => { setPingData([]) setTcpData([]) setStatusData([]) - setError(null) - lastProbeAt.current = 0 + setPingError(null) + setTcpError(null) + setTaskReadable(true) if (!pool || !source || !uuid) return const entry = pool.entries.find(e => e.name === source) @@ -132,10 +102,16 @@ export function useNodeLatency( ]) if (cancelled) return - const queryErrors = [pingHour, tcpHour, pingDay, tcpDay, pingRecent, tcpRecent] - .filter((r): r is PromiseRejectedResult => r.status === 'rejected') - .map(r => r.reason instanceof Error ? r.reason.message : String(r.reason)) - setError(queryErrors[0] ?? null) + const toMessage = (r: PromiseSettledResult) => + r.status === 'rejected' + ? r.reason instanceof Error ? r.reason.message : String(r.reason) + : null + const pingErrors = [pingHour, pingDay, pingRecent].map(toMessage).filter(Boolean) as string[] + const tcpErrors = [tcpHour, tcpDay, tcpRecent].map(toMessage).filter(Boolean) as string[] + const denied = [...pingErrors, ...tcpErrors].some(e => /permission denied|missing task/i.test(e)) + setPingError(pingErrors[0] ?? null) + setTcpError(tcpErrors[0] ?? null) + setTaskReadable(!denied) const fallbackRows = mergeRows( pingRecent.status === 'fulfilled' ? pingRecent.value : [], @@ -145,47 +121,16 @@ export function useNodeLatency( const fallbackPing = fallbackRows.filter(r => matchesLatencyType(r, 'ping')) const fallbackTcp = fallbackRows.filter(r => matchesLatencyType(r, 'tcp_ping')) - let nextPingHour = mergeRows( + const nextPingHour = mergeRows( pingHour.status === 'fulfilled' ? pingHour.value : [], fallbackPing.filter(r => inWindow(r, hourWindow[0], hourWindow[1])), ) - let nextTcpHour = mergeRows( + const nextTcpHour = mergeRows( tcpHour.status === 'fulfilled' ? tcpHour.value : [], fallbackTcp.filter(r => inWindow(r, hourWindow[0], hourWindow[1])), ) - let nextPingDay = mergeRows(pingDay.status === 'fulfilled' ? pingDay.value : [], fallbackPing) - let nextTcpDay = mergeRows(tcpDay.status === 'fulfilled' ? tcpDay.value : [], fallbackTcp) - - const shouldProbe = (!nextPingHour.length || !nextTcpHour.length) && now - lastProbeAt.current > LIVE_PROBE_INTERVAL_MS - const target = shouldProbe ? probeTarget(entry.backend_url) : null - if (target) { - lastProbeAt.current = now - const [pingProbe, tcpProbe] = await Promise.allSettled([ - !nextPingHour.length - ? taskCreateBlocking(entry.client, uuid, { ping: target.ping }, LIVE_PROBE_TIMEOUT_MS) - : Promise.resolve(null), - !nextTcpHour.length - ? taskCreateBlocking(entry.client, uuid, { tcp_ping: target.tcp }, LIVE_PROBE_TIMEOUT_MS) - : Promise.resolve(null), - ]) - - if (cancelled) return - const probeErrors = [pingProbe, tcpProbe] - .filter((r): r is PromiseRejectedResult => r.status === 'rejected') - .map(r => r.reason instanceof Error ? r.reason.message : String(r.reason)) - if (probeErrors.length) setError(probeErrors[0]) - - if (pingProbe.status === 'fulfilled' && pingProbe.value) { - const row = probeRow(uuid, 'ping', target.ping, pingProbe.value) - nextPingHour = mergeRows(nextPingHour, [row]) - nextPingDay = mergeRows(nextPingDay, [row]) - } - if (tcpProbe.status === 'fulfilled' && tcpProbe.value) { - const row = probeRow(uuid, 'tcp_ping', target.tcp, tcpProbe.value) - nextTcpHour = mergeRows(nextTcpHour, [row]) - nextTcpDay = mergeRows(nextTcpDay, [row]) - } - } + const nextPingDay = mergeRows(pingDay.status === 'fulfilled' ? pingDay.value : [], fallbackPing) + const nextTcpDay = mergeRows(tcpDay.status === 'fulfilled' ? tcpDay.value : [], fallbackTcp) setPingData(nextPingHour) setTcpData(nextTcpHour) @@ -201,5 +146,5 @@ export function useNodeLatency( } }, [pool, source, uuid]) - return { pingData, tcpData, statusData, loading, error } + return { pingData, tcpData, statusData, loading, pingError, tcpError, taskReadable } } diff --git a/src/types.ts b/src/types.ts index ca3d1dc..589f9c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,16 +108,6 @@ export interface TaskQueryResult { task_event_result: Record | null } -export interface TaskCreateBlockingResult { - task_id: number - agent_uuid: string - task_token?: string - timestamp: number - success: boolean - error_message?: string | null - task_event_result: Record | null -} - export interface TaskQueryCondition { task_id?: number uuid?: string From 4ca5a2a38c73767499b3ca4c8b6296e95d5fd608 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 12:45:48 +0800 Subject: [PATCH 28/53] Simplify card status indicator --- src/components/NodeCard.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 039df4c..3cc8a06 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -13,7 +13,6 @@ import { Badge } from './ui/badge' import { Card } from './ui/card' import { Progress } from './ui/progress' import { Flag } from './Flag' -import { StatusDot } from './StatusDot' import { bytes, pct, relativeAge, uptime } from '../utils/format' import { cpuLabel, deriveUsage, displayName, distroLogo, osLabel, virtLabel } from '../utils/derive' import { cn, loadColor, loadTextColor } from '../utils/cn' @@ -51,9 +50,6 @@ export function NodeCard({ node }: { node: Node }) { ) : ( )} - - -
@@ -64,9 +60,7 @@ export function NodeCard({ node }: { node: Node }) {
- - {node.online ? 'Online' : 'Offline'} - {virt && / {virt}} + {virt && {virt}}
From 6bf7ba4c56c556d5457391ba636ec306c0487400 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 12:50:59 +0800 Subject: [PATCH 29/53] Unify latency panels and add fleet TCP ping --- src/App.tsx | 3 + src/components/FleetTcpPingPanel.tsx | 73 +++++++++++++++++++ src/components/NodeDetail.tsx | 34 ++++----- src/hooks/useFleetTcpPing.ts | 101 +++++++++++++++++++++++++++ src/styles/global.css | 29 ++++++++ 5 files changed, 223 insertions(+), 17 deletions(-) create mode 100644 src/components/FleetTcpPingPanel.tsx create mode 100644 src/hooks/useFleetTcpPing.ts diff --git a/src/App.tsx b/src/App.tsx index 73cf9bc..be99959 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { NodeDetail } from './components/NodeDetail' import { WorldMap } from './components/WorldMap' import { TagFilter } from './components/TagFilter' import { RegionFilter } from './components/RegionFilter' +import { FleetTcpPingPanel } from './components/FleetTcpPingPanel' import { deriveUsage, displayName } from './utils/derive' import type { Sort, View } from './types' @@ -206,6 +207,8 @@ export function App() { )} {!empty && } + {!empty && view === 'cards' && } + {empty && !hasErrors && (
diff --git a/src/components/FleetTcpPingPanel.tsx b/src/components/FleetTcpPingPanel.tsx new file mode 100644 index 0000000..88fdce2 --- /dev/null +++ b/src/components/FleetTcpPingPanel.tsx @@ -0,0 +1,73 @@ +import { Activity, RadioTower, Zap } from 'lucide-react' +import { Card } from './ui/card' +import { Progress } from './ui/progress' +import { useFleetTcpPing } from '../hooks/useFleetTcpPing' +import { cn } from '../utils/cn' +import type { BackendPool } from '../api/pool' +import type { Node } from '../types' + +interface Props { + pool: BackendPool | null + nodes: Node[] +} + +const carrierStyles: Record = { + 移动: { color: 'text-emerald-300', bar: 'bg-emerald-400' }, + 电信: { color: 'text-cyan-300', bar: 'bg-cyan-400' }, + 联通: { color: 'text-fuchsia-300', bar: 'bg-fuchsia-400' }, +} + +function score(avg: number | null) { + if (avg == null) return 0 + return Math.max(4, Math.min(100, 100 - avg / 5)) +} + +export function FleetTcpPingPanel({ pool, nodes }: Props) { + const { carriers, loading, readable, hasData } = useFleetTcpPing(pool, nodes) + + return ( + +
+
+
+ +
+
+
+ + TCP 三网 Ping +
+
+ {readable ? '聚合最近 TCP 探测任务,展示移动 / 电信 / 联通质量' : '当前 Token 未开放 Task Read: tcp_ping'} +
+
+
+ +
+ {carriers.map(item => { + const style = carrierStyles[item.name] + const pct = score(item.avg) + return ( +
+
+ + + {item.name} + + + {item.avg == null ? '—' : `${Math.round(item.avg)}ms`} + +
+ +
+ {item.count ? `${item.count} 条记录` : loading ? '同步中' : hasData ? '无三网记录' : '暂无数据'} + loss {item.loss == null ? '—' : `${item.loss.toFixed(1)}%`} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index b4db207..5c8c35a 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -392,9 +392,9 @@ function TaskUnsupportedPanel() { ) } -function Section({ title, children }: { title: string; children: ReactNode }) { +function Section({ title, children, className }: { title: string; children: ReactNode; className?: string }) { return ( - +
{title}
{children}
@@ -527,8 +527,8 @@ function LatencyBlock({ title, rows, type, loading, error }: LatencyBlockProps) }) return ( -
-
+
+
{empty && (
{loading ? '加载中…' : error ? `暂无 ${type} 数据 · ${error}` : `暂无 ${type} 数据`} @@ -579,14 +579,14 @@ function LatencyBlock({ title, rows, type, loading, error }: LatencyBlockProps)
{stats.length > 0 && ( -
-
- 来源 - 平均延迟 - 抖动 - 丢包率 +
+
+ 来源 + 平均延迟 + 抖动 + 丢包率
-
+
{stats.map(s => (
@@ -223,7 +222,13 @@ export function App() { {!empty && view === 'cards' && (
{list.map(n => ( - + ))}
)} diff --git a/src/components/FleetTcpPingPanel.tsx b/src/components/FleetTcpPingPanel.tsx index 88fdce2..5486bcd 100644 --- a/src/components/FleetTcpPingPanel.tsx +++ b/src/components/FleetTcpPingPanel.tsx @@ -1,15 +1,6 @@ -import { Activity, RadioTower, Zap } from 'lucide-react' -import { Card } from './ui/card' +import { Activity } from 'lucide-react' import { Progress } from './ui/progress' -import { useFleetTcpPing } from '../hooks/useFleetTcpPing' import { cn } from '../utils/cn' -import type { BackendPool } from '../api/pool' -import type { Node } from '../types' - -interface Props { - pool: BackendPool | null - nodes: Node[] -} const carrierStyles: Record = { 移动: { color: 'text-emerald-300', bar: 'bg-emerald-400' }, @@ -22,52 +13,44 @@ function score(avg: number | null) { return Math.max(4, Math.min(100, 100 - avg / 5)) } -export function FleetTcpPingPanel({ pool, nodes }: Props) { - const { carriers, loading, readable, hasData } = useFleetTcpPing(pool, nodes) +interface Props { + rows: Array<{ name: string; avg: number | null; loss: number | null; count: number }> + loading?: boolean + readable?: boolean +} +export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) { + const hasRows = rows.some(r => r.count > 0) return ( - -
-
-
- -
-
-
- - TCP 三网 Ping -
-
- {readable ? '聚合最近 TCP 探测任务,展示移动 / 电信 / 联通质量' : '当前 Token 未开放 Task Read: tcp_ping'} +
+
+ TCP 三网 Ping + {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'} +
+
+ {rows.map(item => { + const style = carrierStyles[item.name] + const pct = score(item.avg) + return ( +
+ + + {item.name} + + + + {item.avg == null ? '—' : `${Math.round(item.avg)}ms`} +
-
-
- -
- {carriers.map(item => { - const style = carrierStyles[item.name] - const pct = score(item.avg) - return ( -
-
- - - {item.name} - - - {item.avg == null ? '—' : `${Math.round(item.avg)}ms`} - -
- -
- {item.count ? `${item.count} 条记录` : loading ? '同步中' : hasData ? '无三网记录' : '暂无数据'} - loss {item.loss == null ? '—' : `${item.loss.toFixed(1)}%`} -
-
- ) - })} -
+ ) + })} + {!readable && ( +
Token 未开放 tcp_ping 读取
+ )} + {readable && !hasRows && !loading && ( +
暂无该节点三网记录
+ )}
- +
) } diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 3cc8a06..d9a864a 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -13,12 +13,23 @@ import { Badge } from './ui/badge' import { Card } from './ui/card' import { Progress } from './ui/progress' import { Flag } from './Flag' +import { FleetTcpPingPanel } from './FleetTcpPingPanel' import { bytes, pct, relativeAge, uptime } from '../utils/format' import { cpuLabel, deriveUsage, displayName, distroLogo, osLabel, virtLabel } from '../utils/derive' import { cn, loadColor, loadTextColor } from '../utils/cn' import type { Node } from '../types' import type { ReactNode } from 'react' -export function NodeCard({ node }: { node: Node }) { +export function NodeCard({ + node, + tcpPing, + tcpPingLoading, + tcpPingReadable, +}: { + node: Node + tcpPing?: Array<{ name: string; avg: number | null; loss: number | null; count: number }> + tcpPingLoading?: boolean + tcpPingReadable?: boolean +}) { const u = deriveUsage(node) const tags = Array.isArray(node.meta?.tags) ? node.meta.tags : [] const os = osLabel(node) @@ -83,6 +94,10 @@ export function NodeCard({ node }: { node: Node }) { {bytes(u.netOut || 0)}/s
+ {tcpPing && ( + + )} +
{uptime(u.uptime)} { + const nodeMap = new Map() + for (const row of rows) { + const value = latencyValue(row, 'tcp_ping') + if (value == null) continue + const list = nodeMap.get(row.uuid) ?? [] + list.push(row) + nodeMap.set(row.uuid, list) + } + + const out = new Map>() + for (const [uuid, list] of nodeMap) { + const groups = new Map() + for (const row of list) { + const carrier = carrierOf(latencySeriesName(row)) + const group = groups.get(carrier) ?? [] + group.push(row) + groups.set(carrier, group) + } + out.set(uuid, ['移动', '电信', '联通'].map(name => { + const group = groups.get(name) ?? [] + const stats = computeLatencyStats(group, 'tcp_ping') + const vals = stats.flatMap(s => s.avg == null ? [] : [s.avg]) + const avg = vals.length ? vals.reduce((sum, v) => sum + v, 0) / vals.length : null + const loss = stats.length ? stats.reduce((sum, s) => sum + s.lossRate, 0) / stats.length : null + return { name, avg, loss, count: group.length } + })) + } + return out + }, [rows]) + const carriers = useMemo(() => { const groups = new Map() for (const row of rows) { @@ -97,5 +128,5 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { }) }, [rows]) - return { carriers, loading, readable, hasData: rows.length > 0 } + return { carriers, byUuid, loading, readable, hasData: rows.length > 0 } } From 46829001af9a2665ff5379cce5d50d76034f2d63 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 13:05:04 +0800 Subject: [PATCH 31/53] Smooth card ping refresh --- src/components/FleetTcpPingPanel.tsx | 4 ++-- src/components/NodeDetail.tsx | 18 ++++++++++++++++++ src/hooks/useFleetTcpPing.ts | 7 ++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/FleetTcpPingPanel.tsx b/src/components/FleetTcpPingPanel.tsx index 5486bcd..c7211cc 100644 --- a/src/components/FleetTcpPingPanel.tsx +++ b/src/components/FleetTcpPingPanel.tsx @@ -25,7 +25,7 @@ export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) {
TCP 三网 Ping - {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'} + {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'}
{rows.map(item => { @@ -37,7 +37,7 @@ export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) { {item.name} - + {item.avg == null ? '—' : `${Math.round(item.avg)}ms`} diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 5c8c35a..67350ed 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -510,9 +510,20 @@ interface LatencyBlockProps { const ms = (v: number) => `${v.toFixed(1)} ms` +function summarizeStats(stats: LatencyStats[]) { + const valid = stats.filter(s => s.avg != null) + if (!valid.length) return null + const avg = valid.reduce((sum, s) => sum + (s.avg ?? 0), 0) / valid.length + const jitterVals = valid.flatMap(s => s.jitter == null ? [] : [s.jitter]) + const jitter = jitterVals.length ? jitterVals.reduce((sum, v) => sum + v, 0) / jitterVals.length : null + const loss = valid.reduce((sum, s) => sum + s.lossRate, 0) / valid.length + return { avg, jitter, loss } +} + function LatencyBlock({ title, rows, type, loading, error }: LatencyBlockProps) { const { data, series } = useMemo(() => buildLatencyChart(rows, type), [rows, type]) const stats = useMemo(() => computeLatencyStats(rows, type), [rows, type]) + const summary = useMemo(() => summarizeStats(stats), [stats]) const [hidden, setHidden] = useState>(() => new Set()) const empty = data.length === 0 @@ -528,6 +539,13 @@ function LatencyBlock({ title, rows, type, loading, error }: LatencyBlockProps) return (
+ {summary && ( +
+ avg {ms(summary.avg)} + jitter {summary.jitter == null ? '—' : ms(summary.jitter)} + loss = 5 ? 'text-red-400' : 'text-cyan-50')}>{summary.loss.toFixed(1)}% +
+ )}
{empty && (
diff --git a/src/hooks/useFleetTcpPing.ts b/src/hooks/useFleetTcpPing.ts index 6c5edbd..8a637d5 100644 --- a/src/hooks/useFleetTcpPing.ts +++ b/src/hooks/useFleetTcpPing.ts @@ -38,9 +38,9 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { () => nodes.filter(n => n.online).slice(0, MAX_NODES).map(n => ({ source: n.source, uuid: n.uuid })), [nodes], ) + const idsKey = useMemo(() => ids.map(id => `${id.source}:${id.uuid}`).join('|'), [ids]) useEffect(() => { - setRows([]) setReadable(true) if (!pool || !ids.length) return let cancelled = false @@ -64,7 +64,8 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { r => r.status === 'rejected' && /permission denied|missing task/i.test(r.reason instanceof Error ? r.reason.message : String(r.reason)), ) setReadable(!denied) - setRows(mergeRows(settled.flatMap(r => r.status === 'fulfilled' ? [r.value] : []))) + const nextRows = mergeRows(settled.flatMap(r => r.status === 'fulfilled' ? [r.value] : [])) + setRows(prev => nextRows.length ? nextRows : prev) setLoading(false) } @@ -74,7 +75,7 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { cancelled = true clearInterval(timer) } - }, [pool, ids]) + }, [pool, idsKey]) const byUuid = useMemo(() => { const nodeMap = new Map() From 2c35a835d4f516fdc04f89c717010c8670f40369 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 13:12:58 +0800 Subject: [PATCH 32/53] Refine detail status and card ping --- src/components/FleetTcpPingPanel.tsx | 14 +++---- src/components/NodeCard.tsx | 12 ++++-- src/components/NodeDetail.tsx | 58 ++++++---------------------- src/hooks/useFleetTcpPing.ts | 4 +- src/styles/global.css | 40 ++++++++++++++----- 5 files changed, 59 insertions(+), 69 deletions(-) diff --git a/src/components/FleetTcpPingPanel.tsx b/src/components/FleetTcpPingPanel.tsx index c7211cc..c2e96b1 100644 --- a/src/components/FleetTcpPingPanel.tsx +++ b/src/components/FleetTcpPingPanel.tsx @@ -3,9 +3,9 @@ import { Progress } from './ui/progress' import { cn } from '../utils/cn' const carrierStyles: Record = { - 移动: { color: 'text-emerald-300', bar: 'bg-emerald-400' }, - 电信: { color: 'text-cyan-300', bar: 'bg-cyan-400' }, - 联通: { color: 'text-fuchsia-300', bar: 'bg-fuchsia-400' }, + 移动: { color: 'text-lime-300', bar: 'bg-lime-400' }, + 电信: { color: 'text-amber-300', bar: 'bg-amber-400' }, + 联通: { color: 'text-orange-300', bar: 'bg-orange-400' }, } function score(avg: number | null) { @@ -22,8 +22,8 @@ interface Props { export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) { const hasRows = rows.some(r => r.count > 0) return ( -
-
+
+
TCP 三网 Ping {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'}
@@ -37,8 +37,8 @@ export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) { {item.name} - - + + {item.avg == null ? '—' : `${Math.round(item.avg)}ms`}
diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index d9a864a..fc39c5f 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -19,6 +19,13 @@ import { cpuLabel, deriveUsage, displayName, distroLogo, osLabel, virtLabel } fr import { cn, loadColor, loadTextColor } from '../utils/cn' import type { Node } from '../types' import type { ReactNode } from 'react' + +const EMPTY_TCP_PING = [ + { name: '移动', avg: null, loss: null, count: 0 }, + { name: '电信', avg: null, loss: null, count: 0 }, + { name: '联通', avg: null, loss: null, count: 0 }, +] + export function NodeCard({ node, tcpPing, @@ -38,6 +45,7 @@ export function NodeCard({ const cpu = cpuLabel(node) const updated = relativeAge(u.ts) const updateState = node.online ? 'ONLINE' : 'OFFLINE' + const tcpPingRows = tcpPing ?? EMPTY_TCP_PING const Wrapper = node.online ? 'a' : 'div' const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.id)}` } : {} @@ -94,9 +102,7 @@ export function NodeCard({ {bytes(u.netOut || 0)}/s
- {tcpPing && ( - - )} +
{uptime(u.uptime)} diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 67350ed..f22fc20 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -209,14 +209,7 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { )}
-
-
- -
-
- -
-
+
{taskReadable ? ( @@ -280,13 +273,15 @@ export function NodeDetail({ node, onClose, showSource, pool }: Props) { } type HourState = 'online' | 'partial_offline' | 'fully_offline' | 'unknown' +const STATUS_SLOT_MS = 10 * 60 * 1000 +const STATUS_SLOT_COUNT = 24 * 6 function buildHourState(rows: TaskQueryResult[]): HourState[] { const now = Date.now() const states: HourState[] = [] - for (let i = 23; i >= 0; i--) { - const start = now - (i + 1) * 60 * 60 * 1000 - const end = now - i * 60 * 60 * 1000 + for (let i = STATUS_SLOT_COUNT - 1; i >= 0; i--) { + const start = now - (i + 1) * STATUS_SLOT_MS + const end = now - i * STATUS_SLOT_MS const bucket = rows.filter(r => { const ts = r.timestamp < 1_000_000_000_000 ? r.timestamp * 1000 : r.timestamp return ts >= start && ts < end @@ -325,13 +320,13 @@ function OnlinePanel({ {onlineRatio == null ? (loading ? '同步中' : nodeOnline ? '在线' : '未知') : `${onlineRatio}%`}
-
+
{states.map((state, idx) => (
在线 - 有离线 - 整小时离线 + 10分钟内有离线 + 10分钟离线 无记录
) } -function TcpMiniPanel({ rows, taskReadable }: { rows: TaskQueryResult[]; taskReadable: boolean }) { - const stats = useMemo(() => computeLatencyStats(rows, 'tcp_ping').slice(0, 5), [rows]) - return ( -
-
TCP Ping 质量 / HTOP 风格
- {stats.map(s => ( -
-
- {s.name} -
-
-
- {s.avg != null ? `${Math.round(s.avg)}ms` : '—'} -
-
- jitter {s.jitter != null ? `${Math.round(s.jitter)}ms` : '—'} · 丢包 {s.lossRate.toFixed(1)}% -
-
- ))} - {stats.length === 0 && ( -
- {taskReadable ? '暂无 TCP ping 数据' : '当前 Token 未开放任务探测数据'} -
- )} -
- ) -} - function TaskUnsupportedPanel() { return (
diff --git a/src/hooks/useFleetTcpPing.ts b/src/hooks/useFleetTcpPing.ts index 8a637d5..85659eb 100644 --- a/src/hooks/useFleetTcpPing.ts +++ b/src/hooks/useFleetTcpPing.ts @@ -7,7 +7,7 @@ import type { Node, TaskQueryResult } from '../types' const REFRESH_MS = 60_000 const QUERY_TIMEOUT_MS = 15_000 const PER_NODE_LIMIT = 24 -const MAX_NODES = 32 +const MAX_NODES = 160 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { return (rows ?? []) @@ -35,7 +35,7 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { const [readable, setReadable] = useState(true) const ids = useMemo( - () => nodes.filter(n => n.online).slice(0, MAX_NODES).map(n => ({ source: n.source, uuid: n.uuid })), + () => nodes.slice(0, MAX_NODES).map(n => ({ source: n.source, uuid: n.uuid })), [nodes], ) const idsKey = useMemo(() => ids.map(id => `${id.source}:${id.uuid}`).join('|'), [ids]) diff --git a/src/styles/global.css b/src/styles/global.css index 52f0cb3..5370405 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -132,15 +132,15 @@ position: relative; overflow: hidden; background: - linear-gradient(145deg, rgba(8, 18, 40, 0.96), rgba(15, 23, 42, 0.94) 46%, rgba(27, 25, 53, 0.92)), - linear-gradient(90deg, rgba(34, 211, 238, 0.12), transparent 28%, rgba(52, 211, 153, 0.08) 70%, rgba(244, 63, 94, 0.10)); - border-color: rgba(103, 232, 249, 0.26); + linear-gradient(145deg, rgba(9, 19, 28, 0.97), rgba(17, 22, 31, 0.94) 48%, rgba(28, 24, 31, 0.92)), + linear-gradient(90deg, rgba(132, 204, 22, 0.08), transparent 30%, rgba(251, 191, 36, 0.08) 72%, rgba(34, 211, 238, 0.06)); + border-color: rgba(251, 191, 36, 0.22); box-shadow: inset 0 1px 0 rgba(236, 254, 255, 0.08), - inset 0 -1px 0 rgba(34, 211, 238, 0.08), + inset 0 -1px 0 rgba(251, 191, 36, 0.08), 0 18px 36px -22px rgba(2, 6, 23, 0.9), - 0 0 0 1px rgba(34, 211, 238, 0.14), - 0 0 32px -20px rgba(45, 212, 191, 0.85); + 0 0 0 1px rgba(251, 191, 36, 0.12), + 0 0 32px -20px rgba(132, 204, 22, 0.7); isolation: isolate; } @@ -161,7 +161,7 @@ inset: -1px; border-radius: inherit; padding: 1px; - background: linear-gradient(120deg, rgba(103, 232, 249, 0.65), rgba(56, 189, 248, 0.05) 34%, rgba(52, 211, 153, 0.48) 72%, rgba(251, 113, 133, 0.45)); + background: linear-gradient(120deg, rgba(132, 204, 22, 0.5), rgba(251, 191, 36, 0.08) 34%, rgba(34, 211, 238, 0.28) 72%, rgba(251, 191, 36, 0.38)); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; @@ -210,9 +210,9 @@ position: absolute; inset: 0; background-image: - linear-gradient(to right, rgba(125, 211, 252, 0.075) 1px, transparent 1px), - linear-gradient(to bottom, rgba(125, 211, 252, 0.055) 1px, transparent 1px), - linear-gradient(135deg, transparent 0 47%, rgba(52, 211, 153, 0.13) 48% 49%, transparent 50% 100%); + linear-gradient(to right, rgba(251, 191, 36, 0.06) 1px, transparent 1px), + linear-gradient(to bottom, rgba(132, 204, 22, 0.045) 1px, transparent 1px), + linear-gradient(135deg, transparent 0 47%, rgba(132, 204, 22, 0.10) 48% 49%, transparent 50% 100%); background-size: 18px 18px, 18px 18px, 96px 96px; mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.54), transparent 78%); opacity: 0.82; @@ -265,6 +265,26 @@ z-index: 1; } +.retro-terminal { + position: relative; + overflow: hidden; +} + +.retro-terminal::after { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 4px); + opacity: 0.35; + pointer-events: none; + mix-blend-mode: screen; +} + +.retro-terminal > * { + position: relative; + z-index: 1; +} + @keyframes float-glow { 0% { transform: translate3d(-2%, -2%, 0) scale(1); From b521aca56e64b182b08fdf728598f5f686e3b9e0 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 13:18:23 +0800 Subject: [PATCH 33/53] Style TCPing as segmented card --- src/components/FleetTcpPingPanel.tsx | 63 +++++++++++++++++++--------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/src/components/FleetTcpPingPanel.tsx b/src/components/FleetTcpPingPanel.tsx index c2e96b1..d63fd4f 100644 --- a/src/components/FleetTcpPingPanel.tsx +++ b/src/components/FleetTcpPingPanel.tsx @@ -1,16 +1,25 @@ import { Activity } from 'lucide-react' -import { Progress } from './ui/progress' import { cn } from '../utils/cn' -const carrierStyles: Record = { - 移动: { color: 'text-lime-300', bar: 'bg-lime-400' }, - 电信: { color: 'text-amber-300', bar: 'bg-amber-400' }, - 联通: { color: 'text-orange-300', bar: 'bg-orange-400' }, +const carrierStyles: Record = { + 电信: { color: 'text-sky-200', fill: 'bg-orange-400' }, + 联通: { color: 'text-violet-200', fill: 'bg-rose-400' }, + 移动: { color: 'text-lime-200', fill: 'bg-amber-400' }, } -function score(avg: number | null) { +function score(avg: number | null, loss: number | null) { if (avg == null) return 0 - return Math.max(4, Math.min(100, 100 - avg / 5)) + const latencyScore = 100 - avg / 5 + const lossPenalty = (loss ?? 0) * 2 + return Math.max(2, Math.min(100, latencyScore - lossPenalty)) +} + +function segmentClass(index: number, activeSegments: number, loss: number | null, fill: string) { + if (index >= activeSegments) return 'bg-slate-950/90' + const lossRate = loss ?? 0 + if (lossRate >= 8 && index % 3 === 1) return 'bg-rose-400' + if (lossRate >= 3 && index % 5 === 2) return 'bg-yellow-300' + return fill } interface Props { @@ -22,25 +31,39 @@ interface Props { export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) { const hasRows = rows.some(r => r.count > 0) return ( -
-
- TCP 三网 Ping - {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'} +
+
+ + + 三网 TCPing + + {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'}
-
+
{rows.map(item => { const style = carrierStyles[item.name] - const pct = score(item.avg) + const pct = score(item.avg, item.loss) + const activeSegments = Math.round((pct / 100) * 24) return ( -
- - +
+ {item.name} - - - {item.avg == null ? '—' : `${Math.round(item.avg)}ms`} - +
+ {Array.from({ length: 24 }).map((_, idx) => ( + + ))} +
+
+
{item.avg == null ? '—' : `${Math.round(item.avg)}ms`}
+
{item.loss == null ? '—' : `${item.loss.toFixed(0)}%`}
+
) })} From 6b1e85ac89447f0f2da44152c46b4bf5ca1aa617 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 14:25:22 +0800 Subject: [PATCH 34/53] =?UTF-8?q?feat:=20=E8=B5=9B=E5=8D=9A=E9=A3=8E?= =?UTF-8?q?=E6=A0=BCTCPing=E5=BB=B6=E8=BF=9F=E7=83=AD=E5=8A=9B=E5=9B=BE=20?= =?UTF-8?q?+=20=E5=8D=A1=E7=89=87=E7=AD=89=E9=AB=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FleetTcpPingPanel: 24竖条=24小时延迟热力图, 绿(≤80ms)/黄(80-150ms)/红(>150ms) - useFleetTcpPing: 新增小时级分桶计算 computeHourlyBuckets - NodeCard: flex布局修复卡片不等高问题, h-full撑满网格单元格 --- src/components/FleetTcpPingPanel.tsx | 91 +++++++++++++++++++--------- src/components/NodeCard.tsx | 32 ++++++---- src/hooks/useFleetTcpPing.ts | 41 ++++++++++++- 3 files changed, 122 insertions(+), 42 deletions(-) diff --git a/src/components/FleetTcpPingPanel.tsx b/src/components/FleetTcpPingPanel.tsx index d63fd4f..53f4290 100644 --- a/src/components/FleetTcpPingPanel.tsx +++ b/src/components/FleetTcpPingPanel.tsx @@ -1,29 +1,42 @@ import { Activity } from 'lucide-react' import { cn } from '../utils/cn' -const carrierStyles: Record = { - 电信: { color: 'text-sky-200', fill: 'bg-orange-400' }, - 联通: { color: 'text-violet-200', fill: 'bg-rose-400' }, - 移动: { color: 'text-lime-200', fill: 'bg-amber-400' }, +/** Traditional latency color thresholds */ +function barColor(avgMs: number | null): string { + if (avgMs == null) return 'bg-slate-800/60' + if (avgMs <= 80) return 'bg-emerald-400' + if (avgMs <= 150) return 'bg-yellow-400' + return 'bg-red-400' } -function score(avg: number | null, loss: number | null) { - if (avg == null) return 0 - const latencyScore = 100 - avg / 5 - const lossPenalty = (loss ?? 0) * 2 - return Math.max(2, Math.min(100, latencyScore - lossPenalty)) +function barGlow(avgMs: number | null): string { + if (avgMs == null) return '' + if (avgMs <= 80) return 'shadow-[0_0_6px_rgba(52,211,153,0.5)]' + if (avgMs <= 150) return 'shadow-[0_0_6px_rgba(250,204,21,0.45)]' + return 'shadow-[0_0_6px_rgba(248,113,113,0.5)]' } -function segmentClass(index: number, activeSegments: number, loss: number | null, fill: string) { - if (index >= activeSegments) return 'bg-slate-950/90' - const lossRate = loss ?? 0 - if (lossRate >= 8 && index % 3 === 1) return 'bg-rose-400' - if (lossRate >= 3 && index % 5 === 2) return 'bg-yellow-300' - return fill +function labelColor(avgMs: number | null): string { + if (avgMs == null) return 'text-slate-500' + if (avgMs <= 80) return 'text-emerald-300' + if (avgMs <= 150) return 'text-yellow-300' + return 'text-red-300' +} + +export interface HourlyBucket { + hour: number // 0-23 + avg: number | null // average latency in ms for that hour + count: number } interface Props { - rows: Array<{ name: string; avg: number | null; loss: number | null; count: number }> + rows: Array<{ + name: string + avg: number | null + loss: number | null + count: number + hourly?: HourlyBucket[] + }> loading?: boolean readable?: boolean } @@ -31,38 +44,60 @@ interface Props { export function FleetTcpPingPanel({ rows, loading, readable = true }: Props) { const hasRows = rows.some(r => r.count > 0) return ( -
+
三网 TCPing - {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'} + + {readable ? (hasRows ? 'LIVE' : loading ? 'SYNC' : 'NO DATA') : 'NO ACCESS'} +
{rows.map(item => { - const style = carrierStyles[item.name] - const pct = score(item.avg, item.loss) - const activeSegments = Math.round((pct / 100) * 24) + const hourly = item.hourly ?? Array.from({ length: 24 }, (_, i) => ({ + hour: i, + avg: null as number | null, + count: 0, + })) + + // Ensure exactly 24 slots + const buckets: (number | null)[] = new Array(24).fill(null) + for (const h of hourly) { + if (h.hour >= 0 && h.hour < 24) { + buckets[h.hour] = h.avg + } + } + return (
- + {item.name} -
- {Array.from({ length: 24 }).map((_, idx) => ( +
+ {buckets.map((avg, idx) => ( ))}
-
{item.avg == null ? '—' : `${Math.round(item.avg)}ms`}
-
{item.loss == null ? '—' : `${item.loss.toFixed(0)}%`}
+
+ {item.avg == null ? '—' : `${Math.round(item.avg)}ms`} +
+
+ {item.loss == null ? '—' : `${item.loss.toFixed(0)}%`} +
) diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index fc39c5f..ccd0092 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -14,13 +14,14 @@ import { Card } from './ui/card' import { Progress } from './ui/progress' import { Flag } from './Flag' import { FleetTcpPingPanel } from './FleetTcpPingPanel' +import type { HourlyBucket } from './FleetTcpPingPanel' import { bytes, pct, relativeAge, uptime } from '../utils/format' import { cpuLabel, deriveUsage, displayName, distroLogo, osLabel, virtLabel } from '../utils/derive' import { cn, loadColor, loadTextColor } from '../utils/cn' import type { Node } from '../types' import type { ReactNode } from 'react' -const EMPTY_TCP_PING = [ +const EMPTY_TCP_PING: Array<{ name: string; avg: number | null; loss: number | null; count: number; hourly?: HourlyBucket[] }> = [ { name: '移动', avg: null, loss: null, count: 0 }, { name: '电信', avg: null, loss: null, count: 0 }, { name: '联通', avg: null, loss: null, count: 0 }, @@ -33,7 +34,7 @@ export function NodeCard({ tcpPingReadable, }: { node: Node - tcpPing?: Array<{ name: string; avg: number | null; loss: number | null; count: number }> + tcpPing?: Array<{ name: string; avg: number | null; loss: number | null; count: number; hourly?: HourlyBucket[] }> tcpPingLoading?: boolean tcpPingReadable?: boolean }) { @@ -50,10 +51,10 @@ export function NodeCard({ const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.id)}` } : {} return ( - +
-
+
+ {/* Header */}
{logo ? ( @@ -84,27 +86,34 @@ export function NodeCard({
+ {/* System Info */} {(os || cpu) && ( -
+
{os && {os}} {cpu && {cpu}}
)} -
+ {/* Metrics */} +
-
+ {/* Network */} +
{bytes(u.netIn || 0)}/s {bytes(u.netOut || 0)}/s
- + {/* TCPing - this grows to fill space */} +
+ +
-
+ {/* Footer */} +
{uptime(u.uptime)}
+ {/* Tags */} {tags.length > 0 && ( -
+
{tags.slice(0, 4).map(t => ( {t} diff --git a/src/hooks/useFleetTcpPing.ts b/src/hooks/useFleetTcpPing.ts index 85659eb..1e5f700 100644 --- a/src/hooks/useFleetTcpPing.ts +++ b/src/hooks/useFleetTcpPing.ts @@ -3,6 +3,7 @@ import { taskQuery } from '../api/methods' import { computeLatencyStats, latencySeriesName, latencyValue } from '../utils/latency' import type { BackendPool } from '../api/pool' import type { Node, TaskQueryResult } from '../types' +import type { HourlyBucket } from '../components/FleetTcpPingPanel' const REFRESH_MS = 60_000 const QUERY_TIMEOUT_MS = 15_000 @@ -29,6 +30,38 @@ function mergeRows(groups: TaskQueryResult[][]) { return clean([...map.values()]) } +function normalizeTs(ts: number) { + return ts < 1_000_000_000_000 ? ts * 1000 : ts +} + +/** Compute hourly buckets (24 hours) from task query results */ +function computeHourlyBuckets(rows: TaskQueryResult[], type: 'tcp_ping'): HourlyBucket[] { + const buckets: { sum: number; count: number }[] = Array.from({ length: 24 }, () => ({ sum: 0, count: 0 })) + + for (const row of rows) { + const val = latencyValue(row, type) + if (val == null) continue + const ms = normalizeTs(row.timestamp) + const hour = new Date(ms).getHours() + buckets[hour].sum += val + buckets[hour].count += 1 + } + + return buckets.map((b, i) => ({ + hour: i, + avg: b.count > 0 ? b.sum / b.count : null, + count: b.count, + })) +} + +export interface CarrierRow { + name: string + avg: number | null + loss: number | null + count: number + hourly?: HourlyBucket[] +} + export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(false) @@ -87,7 +120,7 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { nodeMap.set(row.uuid, list) } - const out = new Map>() + const out = new Map() for (const [uuid, list] of nodeMap) { const groups = new Map() for (const row of list) { @@ -102,7 +135,8 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { const vals = stats.flatMap(s => s.avg == null ? [] : [s.avg]) const avg = vals.length ? vals.reduce((sum, v) => sum + v, 0) / vals.length : null const loss = stats.length ? stats.reduce((sum, s) => sum + s.lossRate, 0) / stats.length : null - return { name, avg, loss, count: group.length } + const hourly = computeHourlyBuckets(group, 'tcp_ping') + return { name, avg, loss, count: group.length, hourly } })) } return out @@ -125,7 +159,8 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { const vals = stats.flatMap(s => s.avg == null ? [] : [s.avg]) const avg = vals.length ? vals.reduce((sum, v) => sum + v, 0) / vals.length : null const loss = stats.length ? stats.reduce((sum, s) => sum + s.lossRate, 0) / stats.length : null - return { name, avg, loss, count: list.length } + const hourly = computeHourlyBuckets(list, 'tcp_ping') + return { name, avg, loss, count: list.length, hourly } }) }, [rows]) From f1f9f76d326c4496ad3a80777f4fc6a57b6aee42 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 14:32:53 +0800 Subject: [PATCH 35/53] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8D=A1?= =?UTF-8?q?=E7=89=87TCPing=E6=97=A0=E6=95=B0=E6=8D=AE=E9=97=AE=E9=A2=98=20?= =?UTF-8?q?=E2=80=94=20=E5=A2=9E=E5=8A=A0=E6=97=A0type=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E7=9A=84fallback=E6=9F=A5=E8=AF=A2+=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFleetTcpPing.ts | 47 ++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/hooks/useFleetTcpPing.ts b/src/hooks/useFleetTcpPing.ts index 1e5f700..0633947 100644 --- a/src/hooks/useFleetTcpPing.ts +++ b/src/hooks/useFleetTcpPing.ts @@ -1,13 +1,16 @@ import { useEffect, useMemo, useState } from 'react' import { taskQuery } from '../api/methods' -import { computeLatencyStats, latencySeriesName, latencyValue } from '../utils/latency' +import { computeLatencyStats, latencySeriesName, latencyTaskType, latencyValue } from '../utils/latency' import type { BackendPool } from '../api/pool' import type { Node, TaskQueryResult } from '../types' import type { HourlyBucket } from '../components/FleetTcpPingPanel' const REFRESH_MS = 60_000 const QUERY_TIMEOUT_MS = 15_000 -const PER_NODE_LIMIT = 24 +/** Limit for the typed query (type: tcp_ping) */ +const TYPED_LIMIT = 100 +/** Limit for the untyped fallback query (all task types) */ +const FALLBACK_LIMIT = 60 const MAX_NODES = 160 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { @@ -34,6 +37,18 @@ function normalizeTs(ts: number) { return ts < 1_000_000_000_000 ? ts * 1000 : ts } +/** + * Check if a row matches tcp_ping — mirrors the detail page's matchesLatencyType logic. + * First checks the task_event_type label, then falls back to checking if we can + * extract a latency value from the result. + */ +function isTcpPingRow(row: TaskQueryResult): boolean { + const taskType = latencyTaskType(row) + if (taskType) return taskType === 'tcp_ping' + // Fallback: if no explicit type, check if we can extract a tcp_ping value + return latencyValue(row, 'tcp_ping') != null +} + /** Compute hourly buckets (24 hours) from task query results */ function computeHourlyBuckets(rows: TaskQueryResult[], type: 'tcp_ping'): HourlyBucket[] { const buckets: { sum: number; count: number }[] = Array.from({ length: 24 }, () => ({ sum: 0, count: 0 })) @@ -80,14 +95,26 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { const fetchOnce = async () => { setLoading(true) + + // For each node fire TWO queries: + // 1) typed: { type: 'tcp_ping', limit: TYPED_LIMIT } — fast path + // 2) untyped: { limit: FALLBACK_LIMIT } — fallback for backends + // that don't support the type condition filter const jobs = ids.flatMap(({ source, uuid }) => { const entry = pool.entries.find(e => e.name === source) if (!entry) return [] - return taskQuery( - entry.client, - [{ uuid }, { type: 'tcp_ping' }, { limit: PER_NODE_LIMIT }], - QUERY_TIMEOUT_MS, - ) + return [ + taskQuery( + entry.client, + [{ uuid }, { type: 'tcp_ping' }, { limit: TYPED_LIMIT }], + QUERY_TIMEOUT_MS, + ), + taskQuery( + entry.client, + [{ uuid }, { limit: FALLBACK_LIMIT }], + QUERY_TIMEOUT_MS, + ), + ] }) const settled = await Promise.allSettled(jobs) @@ -97,7 +124,11 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { r => r.status === 'rejected' && /permission denied|missing task/i.test(r.reason instanceof Error ? r.reason.message : String(r.reason)), ) setReadable(!denied) - const nextRows = mergeRows(settled.flatMap(r => r.status === 'fulfilled' ? [r.value] : [])) + + // Merge all results, then client-side filter for tcp_ping rows only + const allRows = mergeRows(settled.flatMap(r => r.status === 'fulfilled' ? [r.value] : [])) + const nextRows = allRows.filter(isTcpPingRow) + setRows(prev => nextRows.length ? nextRows : prev) setLoading(false) } From fca843ed730156209ccd659d2a54652cb54393dd Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 14:40:22 +0800 Subject: [PATCH 36/53] =?UTF-8?q?fix:=20=E9=87=8D=E6=9E=84fleet=20TCPing?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=20=E2=80=94=20=E6=94=B9=E4=B8=BA=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E6=89=B9=E9=87=8F=E6=9F=A5=E8=AF=A2+=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E8=BF=87=E6=BB=A4,=20=E9=81=BF=E5=85=8DN?= =?UTF-8?q?=E4=B8=AAper-node=E8=AF=B7=E6=B1=82=E5=8E=8B=E5=9E=AE=E5=90=8E?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFleetTcpPing.ts | 81 +++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/hooks/useFleetTcpPing.ts b/src/hooks/useFleetTcpPing.ts index 0633947..188d77b 100644 --- a/src/hooks/useFleetTcpPing.ts +++ b/src/hooks/useFleetTcpPing.ts @@ -6,12 +6,11 @@ import type { Node, TaskQueryResult } from '../types' import type { HourlyBucket } from '../components/FleetTcpPingPanel' const REFRESH_MS = 60_000 -const QUERY_TIMEOUT_MS = 15_000 -/** Limit for the typed query (type: tcp_ping) */ -const TYPED_LIMIT = 100 -/** Limit for the untyped fallback query (all task types) */ -const FALLBACK_LIMIT = 60 +const QUERY_TIMEOUT_MS = 20_000 const MAX_NODES = 160 +const DAY_MS = 24 * 60 * 60 * 1000 +/** Max results for the global (no-uuid) query */ +const GLOBAL_LIMIT = 5000 function clean(rows: TaskQueryResult[] | undefined): TaskQueryResult[] { return (rows ?? []) @@ -38,14 +37,12 @@ function normalizeTs(ts: number) { } /** - * Check if a row matches tcp_ping — mirrors the detail page's matchesLatencyType logic. - * First checks the task_event_type label, then falls back to checking if we can - * extract a latency value from the result. + * Client-side filter: keep only rows that look like tcp_ping tasks. + * Mirrors detail page's matchesLatencyType logic. */ function isTcpPingRow(row: TaskQueryResult): boolean { const taskType = latencyTaskType(row) if (taskType) return taskType === 'tcp_ping' - // Fallback: if no explicit type, check if we can extract a tcp_ping value return latencyValue(row, 'tcp_ping') != null } @@ -82,52 +79,60 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { const [loading, setLoading] = useState(false) const [readable, setReadable] = useState(true) - const ids = useMemo( - () => nodes.slice(0, MAX_NODES).map(n => ({ source: n.source, uuid: n.uuid })), + // Set of known UUIDs for filtering + const uuidSet = useMemo( + () => new Set(nodes.slice(0, MAX_NODES).map(n => n.uuid)), [nodes], ) - const idsKey = useMemo(() => ids.map(id => `${id.source}:${id.uuid}`).join('|'), [ids]) + const uuidKey = useMemo(() => [...uuidSet].sort().join('|'), [uuidSet]) useEffect(() => { setReadable(true) - if (!pool || !ids.length) return + if (!pool || !uuidSet.size) return let cancelled = false const fetchOnce = async () => { setLoading(true) - - // For each node fire TWO queries: - // 1) typed: { type: 'tcp_ping', limit: TYPED_LIMIT } — fast path - // 2) untyped: { limit: FALLBACK_LIMIT } — fallback for backends - // that don't support the type condition filter - const jobs = ids.flatMap(({ source, uuid }) => { - const entry = pool.entries.find(e => e.name === source) - if (!entry) return [] - return [ - taskQuery( - entry.client, - [{ uuid }, { type: 'tcp_ping' }, { limit: TYPED_LIMIT }], - QUERY_TIMEOUT_MS, - ), - taskQuery( - entry.client, - [{ uuid }, { limit: FALLBACK_LIMIT }], - QUERY_TIMEOUT_MS, - ), - ] - }) + const now = Date.now() + const dayWindow: [number, number] = [now - DAY_MS, now] + + // Strategy: query ALL tcp_ping tasks globally (no uuid filter) per backend. + // This avoids N per-node queries and gets all data in 2-3 calls per backend. + // Then filter client-side by known UUIDs. + const jobs = pool.entries.flatMap(entry => [ + // 1) All tcp_ping tasks in the last 24h (most reliable) + taskQuery( + entry.client, + [{ timestamp_from_to: dayWindow }, { type: 'tcp_ping' }], + QUERY_TIMEOUT_MS, + ), + // 2) Recent tcp_ping by limit (fallback for backends without time filter) + taskQuery( + entry.client, + [{ type: 'tcp_ping' }, { limit: GLOBAL_LIMIT }], + QUERY_TIMEOUT_MS, + ), + // 3) All recent tasks without type filter (fallback for backends without type support) + taskQuery( + entry.client, + [{ timestamp_from_to: dayWindow }, { limit: GLOBAL_LIMIT }], + QUERY_TIMEOUT_MS, + ), + ]) const settled = await Promise.allSettled(jobs) if (cancelled) return const denied = settled.some( - r => r.status === 'rejected' && /permission denied|missing task/i.test(r.reason instanceof Error ? r.reason.message : String(r.reason)), + r => r.status === 'rejected' && /permission denied|missing task/i.test( + r.reason instanceof Error ? r.reason.message : String(r.reason), + ), ) setReadable(!denied) - // Merge all results, then client-side filter for tcp_ping rows only + // Merge, filter by tcp_ping type, then filter by known UUIDs const allRows = mergeRows(settled.flatMap(r => r.status === 'fulfilled' ? [r.value] : [])) - const nextRows = allRows.filter(isTcpPingRow) + const nextRows = allRows.filter(r => isTcpPingRow(r) && uuidSet.has(r.uuid)) setRows(prev => nextRows.length ? nextRows : prev) setLoading(false) @@ -139,7 +144,7 @@ export function useFleetTcpPing(pool: BackendPool | null, nodes: Node[]) { cancelled = true clearInterval(timer) } - }, [pool, idsKey]) + }, [pool, uuidKey]) const byUuid = useMemo(() => { const nodeMap = new Map() From b5b8bfba23f1154175410a2b4d27709acbfe15e7 Mon Sep 17 00:00:00 2001 From: Flanker Date: Wed, 6 May 2026 14:47:13 +0800 Subject: [PATCH 37/53] =?UTF-8?q?style:=20=E8=80=81=E6=B4=BE=E8=B5=9B?= =?UTF-8?q?=E5=8D=9A=E6=9C=8B=E5=85=8B=E9=A3=8E=E6=A0=BC=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E6=94=B9=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - global.css: 深黑背景+CRT扫描线+网格+霓虹青/品红色系 - Orbitron+Share Tech Mono字体, 终端质感 - Navbar: 霓虹发光站名, 赛博导航条 - Footer/RegionFilter/TagFilter: 统一赛博芯片按钮风格 - Search: 霓虹边框终端输入框 - 锁定暗色模式, 移除ThemeToggle - 卡片边框脉冲动画, 扫描光效, 全息边框 --- src/components/Footer.tsx | 8 +- src/components/Navbar.tsx | 18 +- src/components/RegionFilter.tsx | 10 +- src/components/Search.tsx | 7 +- src/components/TagFilter.tsx | 6 +- src/hooks/useTheme.ts | 23 +- src/styles/global.css | 427 ++++++++++++++++++++------------ 7 files changed, 293 insertions(+), 206 deletions(-) diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 9d2a716..25b3473 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -16,15 +16,15 @@ export function Footer({ text }: { text?: string }) { const outdated = latest != null && latest !== __APP_VERSION__ return ( -