Files
MoShiro/web/shared/nodes.tsx
T

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、Probe)的展示信息:国旗、运营商、系统和架构。
* 后端只给国家代码、运营商名、系统名和架构——没有 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' },
probe_paused: { 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