From 83f54978d254e5f795db9487e57b6d3a4a181d28 Mon Sep 17 00:00:00 2001 From: JustMe Date: Tue, 6 Oct 2026 21:22:04 +0800 Subject: [PATCH] feat(web): acrylic theme, node flags and icons, build info Acrylic cards and topbar over a blurred background, country flags, ISP and OS icons, per-node version tags, a build info footer and an admin versions tab. Overview cards drop the sparkline and widen the hover area. --- docs/api.md | 12 +- docs/deployment.md | 4 + web/admin/src/pages/Agents.tsx | 9 +- web/admin/src/pages/Probes.tsx | 3 + web/admin/src/pages/System.tsx | 79 ++++++- web/admin/vite.config.ts | 2 + web/build-info.d.mts | 1 + web/build-info.mjs | 25 +++ web/display/src/App.tsx | 8 +- web/display/src/BuildFooter.tsx | 29 +++ web/display/src/LineStrips.tsx | 76 ++----- web/display/src/assets/bg.jpg | Bin 0 -> 159532 bytes web/display/src/display.css | 325 +++++++++++++++++++++++------ web/display/src/pages/Line.tsx | 10 + web/display/src/pages/Overview.tsx | 54 ++++- web/display/vite.config.ts | 2 + web/package-lock.json | 31 ++- web/package.json | 9 +- web/shared/api/schema.ts | 188 +++++++++++++++++ web/shared/api/types.ts | 5 + web/shared/assets/isp/mobile.svg | 67 ++++++ web/shared/assets/isp/telecom.svg | 142 +++++++++++++ web/shared/assets/isp/unicom.svg | 78 +++++++ web/shared/buildInfo.ts | 21 ++ web/shared/i18n.ts | 1 + web/shared/nodes.tsx | 104 +++++++++ web/shared/osIcon.tsx | 69 ++++++ web/shared/theme/base.css | 92 ++++++++ web/shared/theme/tokens.css | 7 + 29 files changed, 1310 insertions(+), 143 deletions(-) create mode 100644 web/build-info.d.mts create mode 100644 web/build-info.mjs create mode 100644 web/display/src/BuildFooter.tsx create mode 100644 web/display/src/assets/bg.jpg create mode 100644 web/shared/assets/isp/mobile.svg create mode 100644 web/shared/assets/isp/telecom.svg create mode 100644 web/shared/assets/isp/unicom.svg create mode 100644 web/shared/buildInfo.ts create mode 100644 web/shared/nodes.tsx create mode 100644 web/shared/osIcon.tsx diff --git a/docs/api.md b/docs/api.md index 9215bb4..1e847ce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,15 @@ `host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。 +### 2.3.2 节点的展示信息 + +`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。**没有 IP 地址、城市、ASN、经纬度**,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。 + +`GET /api/public/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。 + +- 系统和架构是节点自报的:Agent 在 `fleet.hello` 里,Probe 随列表同步(`monitor.probe.agents` 的 `platform`)告诉 Agent,Agent 再放进心跳的 `probes[]`。只有粗粒度的名字,没有主机名、内核版本、CPU 型号。 +- 国家和运营商由 **Backend** 用节点的来源地址向 IP 归属服务查一次(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`,留空就不查):Probe 用 Agent 看到的来源 IP,Agent 用它的公开地址。**Probe 和 Agent 不访问任何外部服务。** 只查公网地址(回环、内网、CGNAT、链路本地一律不发出去);同一个地址一周内只查一次,失败 10 分钟后再试;查到的只留国家代码和运营商,地址只存在 Backend 内部的 `node_meta.geo_host`,不对外。 + ### 2.4 测试结果与目录 - `GET /api/public/tests/{test_id}`:包括计划、参数、状态、阶段,`metrics` 与 `scores`,`series`(例如码率–丢包曲线),以及置信说明 `caveats`。 @@ -125,7 +134,8 @@ | GET | `/api/admin/releases` | 各应用、各通道的当前版本,以及节点的版本分布(由 `Fleet.Server.Releases` 提供) | | POST | `/api/admin/releases/{app}/{channel}/rollout` | `{version, batches:[{labels?, count?}], soak_minutes}`:分批灰度;只能选择已经签名的清单 | | POST | `/api/admin/releases/{app}/approve` | 对 `policy = notify` 的节点批准更新 `{version, agents?}` | -| GET/PATCH | `/api/admin/settings` | 站点公开或私有、保留期等 | +| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期等 | +| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统(管理端「系统 → 版本」) | | GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | 任务的请求体示例: diff --git a/docs/deployment.md b/docs/deployment.md index e8f524d..38b5d38 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,6 +9,8 @@ scripts/publish.sh → backend/art scripts/package.sh → backend/artifacts/packages/monitor---.tar.gz(附 .sha256) ``` +构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MONITOR_TAG`、`MONITOR_COMMIT`、`MONITOR_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器的 Fastlane 构建用同名的 `--build-arg`);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`)。 + | rid | 目标 | 构建方式 | | --- | --- | --- | | `linux-x64`、`linux-arm64` | Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 | @@ -154,6 +156,8 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 - VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。 - 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。 +**国家和运营商**:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。 + **从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。 实测(Probe 在广东移动家宽,Agent 在日本 IIJ): diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 835f23e..9767875 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -5,6 +5,7 @@ import { api } from '@shared/api/client' import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types' import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' +import { NodeTag, useNodeInfos } from '@shared/nodes' import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, guessPlatform, Segmented, Spinner, useAction } from '@shared/ui' export const STATUS: Record = { @@ -136,6 +137,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated export function Agents() { const navigate = useNavigate() const agents = useQuery(() => api.get>('/api/admin/agents'), [], 10_000) + const infos = useNodeInfos() const tokens = useQuery(() => api.get>('/api/admin/join-tokens'), []) const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all') const [adding, setAdding] = useState(false) @@ -193,7 +195,12 @@ export function Agents() { - {a.name} + + + {a.name} + + + {a.public_host || '—'} {a.agent_version ?? '—'} diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index bafa092..6308d41 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -5,6 +5,7 @@ import { api } from '@shared/api/client' import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types' import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' +import { NodeTag, useNodeInfos } from '@shared/nodes' import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' /** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */ @@ -84,6 +85,7 @@ type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto } export function Probes() { const probes = useQuery(() => api.get>('/api/admin/probes'), [], 8000) + const infos = useNodeInfos() const { run } = useAction() const [creating, setCreating] = useState(false) const [renaming, setRenaming] = useState() @@ -123,6 +125,7 @@ export function Probes() {
{p.name} + {p.revoked ? 已吊销 : 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}} {p.id} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index c221e05..875ea66 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,13 +1,14 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { api } from '@shared/api/client' -import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' import { useQuery } from '@shared/hooks' import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' -type Tab = 'settings' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' +type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' function Settings() { const settings = useQuery(() => api.get>('/api/admin/settings'), []) @@ -76,6 +77,77 @@ function Settings() { ) } +/** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */ +function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string }) { + const dash = — + return ( + + {name} + + {kind} + + {version || dash} + {tag || dash} + {commit || dash} + {fmtBuiltAt(builtAt) || dash} + {platform || dash} + + ) +} + +/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */ +function Versions() { + const info = useQuery(() => api.get('/api/admin/build-info'), [], 15_000) + const agents = useQuery(() => api.get>('/api/admin/agents'), [], 30_000) + const probes = useQuery(() => api.get>('/api/admin/probes'), [], 30_000) + const names = new Map([...(agents.data?.items ?? []).map((a) => [a.id, a.name] as const), ...(probes.data?.items ?? []).map((p) => [p.id, p.name] as const)]) + const back = info.data?.backend + const nodes = info.data?.nodes ?? [] + // 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到) + const mismatch = (v?: string | null) => Boolean(v && back && v !== back.version) + return ( +
+ + + + + + + + + + + + + + + {back ? : null} + + {nodes.map((n) => ( + + + + + + + + + + ))} + +
名称类型版本tag提交构建时间系统
{names.get(n.id) ?? n.id} + {n.kind} + + {n.version ? mismatch(n.version) ? {n.version} : n.version : —} + {n.tag || —}{n.commit || —}{fmtBuiltAt(n.built_at) || —}{[n.os_name, n.arch].filter(Boolean).join(' / ') || —}
+ {nodes.length === 0 && !info.loading ? 还没有节点上报过版本 : null} +

+ 版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。 +

+
+ ) +} + function Tokens() { const tokens = useQuery(() => api.get>('/api/admin/api-tokens'), []) const { run } = useAction() @@ -418,6 +490,7 @@ export function System() { - {tab === 'settings' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : } + {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : }
) diff --git a/web/admin/vite.config.ts b/web/admin/vite.config.ts index 549a2e3..d12bc76 100644 --- a/web/admin/vite.config.ts +++ b/web/admin/vite.config.ts @@ -1,6 +1,7 @@ import react from '@vitejs/plugin-react' import { fileURLToPath } from 'node:url' import { defineConfig, loadEnv } from 'vite' +import { buildDefines } from '../build-info.mjs' // 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example): // BACKEND_URL=https://mo.oeo.one npm run dev:display @@ -9,6 +10,7 @@ export default defineConfig(({ mode }) => { const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '') return { base: '/admin/', + define: buildDefines(mode === 'development'), plugins: [react()], resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } }, server: { diff --git a/web/build-info.d.mts b/web/build-info.d.mts new file mode 100644 index 0000000..cddee47 --- /dev/null +++ b/web/build-info.d.mts @@ -0,0 +1 @@ +export function buildDefines(dev: boolean): Record diff --git a/web/build-info.mjs b/web/build-info.mjs new file mode 100644 index 0000000..bfc3ea4 --- /dev/null +++ b/web/build-info.mjs @@ -0,0 +1,25 @@ +import { execSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +/** + * 前端自己的构建信息:版本取自 web/package.json,tag 和提交取自 git(环境变量 MONITOR_TAG / MONITOR_COMMIT 优先,CI 里没有 git 历史时用), + * 构建时间是这次构建的时刻。开发服务器里 tag 是「dev」,免得每次刷新都变。 + */ +function git(args) { + try { + return execSync(`git ${args}`, { cwd: fileURLToPath(new URL('..', import.meta.url)), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() + } catch { + return '' + } +} + +export function buildDefines(dev) { + const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version + return { + __APP_VERSION__: JSON.stringify(version), + __APP_TAG__: JSON.stringify(dev ? 'dev' : (process.env.MONITOR_TAG || git('describe --tags --always --dirty'))), + __APP_COMMIT__: JSON.stringify(dev ? '' : (process.env.MONITOR_COMMIT || git('rev-parse --short=10 HEAD'))), + __APP_BUILT_AT__: JSON.stringify(dev ? '' : (process.env.MONITOR_BUILT_AT || new Date().toISOString())), + } +} diff --git a/web/display/src/App.tsx b/web/display/src/App.tsx index dddf5ab..cd5165a 100644 --- a/web/display/src/App.tsx +++ b/web/display/src/App.tsx @@ -1,3 +1,4 @@ +import { BuildFooter } from './BuildFooter' import { NavLink, Navigate, Route, Routes } from 'react-router-dom' import { SourceButton, ThemeToggle } from '@shared/ui' import { Compare } from './pages/Compare' @@ -9,9 +10,6 @@ export function App() { <>
- - MyMonitor -