Replace Probe pages with directed Agent links, shared deployment controls and unified display data. Add node dragging, canvas pan and zoom, saved layouts, and per-direction link controls.
123 lines
5.0 KiB
TypeScript
123 lines
5.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { api } from './api/client'
|
|
import type { NodeInfoDto, NodeRuntimeStatus, Page } from './api/types'
|
|
import { useQuery } from './hooks'
|
|
import { OsIcon } from './osIcon'
|
|
import { Chip } from './ui'
|
|
import mobileIcon from './assets/isp/mobile.svg?url'
|
|
import telecomIcon from './assets/isp/telecom.svg?url'
|
|
import unicomIcon from './assets/isp/unicom.svg?url'
|
|
|
|
/**
|
|
* 节点(Agent)的展示信息:国旗、运营商、系统和架构。
|
|
* 后端只给国家代码、运营商名、系统名和架构——没有 IP,所以这里也画不出 IP。
|
|
*/
|
|
|
|
/** 所有国旗的 SVG,按需加载:用到哪个国家才会下载哪个文件。 */
|
|
const flagLoaders = import.meta.glob<string>('../node_modules/flag-icons/flags/4x3/*.svg', { query: '?url', import: 'default' })
|
|
|
|
const regionNames = typeof Intl !== 'undefined' && 'DisplayNames' in Intl ? new Intl.DisplayNames(['zh-CN'], { type: 'region' }) : undefined
|
|
|
|
export function regionName(code: string): string {
|
|
try {
|
|
return regionNames?.of(code.toUpperCase()) ?? code
|
|
} catch {
|
|
return code
|
|
}
|
|
}
|
|
|
|
export function Flag({ code }: { code: string }) {
|
|
const [url, setUrl] = useState<string>()
|
|
useEffect(() => {
|
|
const load = flagLoaders[`../node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`]
|
|
if (!load) {
|
|
setUrl(undefined)
|
|
return
|
|
}
|
|
let alive = true
|
|
void load().then((u) => alive && setUrl(u))
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [code])
|
|
const name = regionName(code)
|
|
if (!url) return <span className="flag ph" title={name} />
|
|
return <img className="flag" src={url} alt={name} title={name} />
|
|
}
|
|
|
|
/**
|
|
* 运营商的小图标:三大运营商用它们各自的标志(只取标志,不带文字),广电没有现成的文件,用品牌色方块加一个字代替;
|
|
* 别的运营商没有图标,只显示名字。这些标志是各公司的商标,只用来标明「这是哪家运营商」。
|
|
*/
|
|
const ISP_ICON: Record<string, string> = { mobile: mobileIcon, telecom: telecomIcon, unicom: unicomIcon }
|
|
const ISP_GLYPH: Record<string, string> = { broadnet: '广' }
|
|
|
|
export function IspIcon({ ispKey, name }: { ispKey: string; name?: string | null }) {
|
|
const icon = ISP_ICON[ispKey]
|
|
if (icon) return <img className="isp logo" src={icon} alt={name ?? ispKey} title={name ?? ispKey} />
|
|
const glyph = ISP_GLYPH[ispKey]
|
|
if (!glyph) return null
|
|
return (
|
|
<svg className={`isp isp-${ispKey}`} viewBox="0 0 16 16" role="img" aria-label={name ?? ispKey}>
|
|
<title>{name ?? ispKey}</title>
|
|
<rect width="16" height="16" rx="4" />
|
|
<text x="8" y="12.2" textAnchor="middle">
|
|
{glyph}
|
|
</text>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/** 节点的展示信息,按节点 id 查:`useNodeInfos().get(id)`。每分钟刷新一次。 */
|
|
export function useNodeInfos(): Map<string, NodeInfoDto> {
|
|
const { data } = useQuery(() => api.get<Page<NodeInfoDto>>('/api/public/nodes'), [], 60_000)
|
|
return useMemo(() => new Map((data?.items ?? []).map((n) => [n.id, n])), [data])
|
|
}
|
|
|
|
/** 系统和架构:「Debian 12 · arm64」。 */
|
|
export const platformText = (info: NodeInfoDto | undefined) => [info?.os_name, info?.arch].filter(Boolean).join(' · ')
|
|
|
|
interface TagProps {
|
|
info: NodeInfoDto | undefined
|
|
/** 不写系统和架构,只留国旗和运营商。 */
|
|
geoOnly?: boolean
|
|
}
|
|
|
|
/** 国旗 + 运营商(图标,或者别的运营商的名字)+ 系统和架构。什么都没有就什么也不画。 */
|
|
export function NodeTag({ info, geoOnly }: TagProps) {
|
|
if (!info) return null
|
|
const platform = geoOnly ? '' : platformText(info)
|
|
if (!info.country && !info.isp_name && !platform) return null
|
|
const title = [info.country ? regionName(info.country) : null, info.isp_name, platformText(info)].filter(Boolean).join(' · ')
|
|
return (
|
|
<span className="node-tag" title={title}>
|
|
{info.country ? <Flag code={info.country} /> : null}
|
|
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
|
{info.isp_key === 'other' && info.isp_name ? <span className="isp-name">{info.isp_name}</span> : null}
|
|
{platform ? (
|
|
<span className="plat">
|
|
<OsIcon os={info.os} name={info.os_name} />
|
|
{platform}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const RUNTIME_STATUS: Record<NodeRuntimeStatus, { label: string; tone: 'ok' | 'warn' | 'bad' | 'accent' | '' }> = {
|
|
initializing: { label: '初始化中', tone: 'warn' },
|
|
running: { label: '运行中', tone: 'ok' },
|
|
idle: { label: '空闲', tone: 'warn' },
|
|
maintenance: { label: '维护中', tone: 'warn' },
|
|
stopping: { label: '停止中', tone: 'warn' },
|
|
offline: { label: '失联', tone: 'bad' },
|
|
updating: { label: '更新中', tone: 'accent' },
|
|
}
|
|
|
|
export function RuntimeStatusChip({ status }: { status: NodeRuntimeStatus }) {
|
|
const value = RUNTIME_STATUS[status] ?? { label: status, tone: '' as const }
|
|
return <Chip tone={value.tone}>{value.label}</Chip>
|
|
}
|
|
|
|
export const runtimeStatusLabel = (status: NodeRuntimeStatus) => RUNTIME_STATUS[status]?.label ?? status
|