backend: require confirmation for signed channel updates [CI]
ci/woodpecker/pr/verify Pipeline failed
ci/woodpecker/pr/web-build unknown status

This commit is contained in:
justme committed 2026-10-07 12:57:20 +08:00
1 parent 7160de2a36
commit 84f9d211bc
62 files changed
+1717 -70

No files matched your search

+2
View File
@@ -4,6 +4,7 @@ import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { activateSource, getBase, listSources } from '@shared/api/source'
import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
import { useAuth } from './auth'
import { BackendUpdateNotice } from './BackendUpdateNotice'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
@@ -162,6 +163,7 @@ export function App() {
</div>
</aside>
<div className="main">
<BackendUpdateNotice />
<Routes>
<Route path="/" element={<Navigate to="/agents" replace />} />
<Route path="/agents" element={<Agents />} />
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '@shared/api/client'
import { openLive } from '@shared/api/source'
import type { BackendUpdateDto } from '@shared/api/types'
import { Confirm, ErrorNotice, useAction } from '@shared/ui'
import { useAuth } from './auth'
/** webhook 发布后显示当前通道的更新;宿主机验签完成且用户确认同一个 seq 后才升级。 */
export function BackendUpdateNotice() {
const { me } = useAuth()
const [update, setUpdate] = useState<BackendUpdateDto>()
const [confirming, setConfirming] = useState<BackendUpdateDto>()
const [requested, setRequested] = useState(false)
const [actionError, setActionError] = useState<unknown>()
const originalTag = useRef<string | null | undefined>(undefined)
const { busy, run } = useAction()
useEffect(() => {
if (me?.scope !== 'admin') return
let alive = true
const receive = (value: BackendUpdateDto) => {
if (!alive) return
setUpdate(value)
}
// 兼容尚未升级的 Backend:没有这个端点时隐藏提示;SSE 自动重连后再取得快照。
api.get<BackendUpdateDto>('/api/admin/backend/update').then(receive).catch(() => undefined)
const source = openLive('/api/admin/backend/update/live')
source.addEventListener('backend_update', (event) => receive(JSON.parse(event.data) as BackendUpdateDto))
return () => { alive = false; source.close() }
}, [me?.scope])
useEffect(() => {
if (!requested || !update) return
if (update.state === 'up_to_date' || (update.current_tag && update.current_tag !== originalTag.current)) location.reload()
else if (['failed', 'rolled_back', 'invalid'].includes(update.state) || (update.state === 'available' && update.error)) setRequested(false)
}, [update, requested])
if (!update || (!update.available && !requested && update.state !== 'applying')) return null
const waiting = requested || update.state === 'requested' || update.state === 'applying'
const canUpdate = update.enabled && update.state === 'available' && update.available
const message = update.state === 'applying' ? 'Backend 正在升级重启,连接恢复后会自动刷新。'
: waiting ? '已确认 Backend 更新,正在等待宿主机更新器执行。'
: update.state === 'checking' ? '新版本已发布,正在等待宿主机验签。'
: !update.enabled ? '新版本已发布,需要在宿主机安装 Backend 更新器。'
: update.state === 'invalid' ? '发布清单未通过更新器校验。'
: update.state === 'rolled_back' ? '更新失败,已回滚。'
: update.state === 'failed' ? '更新失败。' : 'Backend 有新版本。'
return (
<div className="notice" style={{ marginBottom: 16 }}>
<div className="row wrap">
<span>{message} <span className="mono">{update.available?.tag}</span> <span className="muted">({update.channel})</span></span>
{canUpdate && !waiting ? <button type="button" className="btn small primary right" disabled={busy} onClick={() => setConfirming(update)}>更新并重启</button> : null}
</div>
{update.error ? <p className="muted">{update.error}</p> : null}
<ErrorNotice error={actionError} />
{confirming?.available ? (
<Confirm title="更新并重启 Backend" confirmText="确认更新并重启" onClose={() => setConfirming(undefined)} onConfirm={async () => {
setActionError(undefined)
const ok = await run(async () => {
try { await api.post('/api/admin/backend/update', { seq: confirming.available!.seq }) }
catch (error) { setActionError(error); throw error }
}, '已确认更新')
if (ok) {
originalTag.current = update.current_tag
setRequested(true)
}
}}>
将从 {update.current_tag || '当前版本'} 升级到 {confirming.available.tag}({confirming.channel})。宿主机会先备份数据,再升级并重启,期间面板会短暂断开;恢复后自动刷新。如果发布清单已变化,需要重新确认。
</Confirm>
) : null}
</div>
)
}
+11
View File
@@ -7,6 +7,7 @@ import { fmtAgo, fmtNum, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { AgentStatus } from './Agents'
import { IPV6_STATES } from '@shared/ipVersions'
function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
@@ -221,6 +222,16 @@ export function AgentDetail() {
<dd className="mono">{a.ipv6_host || '未配置'}</dd>
<dt>IPv6 监听</dt>
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
<dt>IPv6 诊断</dt>
<dd>{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}</dd>
<dt>检测网络</dt>
<dd>{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}</dd>
<dt>宿主全局 IPv6</dt>
<dd>{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}</dd>
<dt>全局 IPv6 地址</dt>
<dd>{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}</dd>
<dt>IPv6 默认路由</dt>
<dd>{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}</dd>
<dt>控制端口</dt>
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
<dt>数据端口</dt>
+2
View File
@@ -6,6 +6,7 @@ import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
/**
@@ -204,6 +205,7 @@ export function Probes() {
<div className="row wrap">
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
<NodeTag info={infos.get(p.id)} />
<Chip tone={p.ipv6?.state === 'ready' ? 'ok' : 'warn'}>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'}</Chip>
{p.revoked ? (
<Chip tone="bad">已吊销</Chip>
) : (
+6 -5
View File
@@ -1,5 +1,5 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
@@ -78,7 +78,7 @@ 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 }) {
function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string; update?: ReactNode }) {
const dash = <span className="faint">—</span>
return (
<tr>
@@ -91,6 +91,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { nam
<td className="mono">{commit || dash}</td>
<td className="muted">{fmtBuiltAt(builtAt) || dash}</td>
<td className="muted">{platform || dash}</td>
<td>{update ?? dash}</td>
</tr>
)
}
@@ -149,8 +150,8 @@ function Versions() {
{(releases.data?.items ?? []).length === 0
? '还没有(CI 构建后会自动登记)'
: (releases.data?.items ?? []).map((r) => (
<span key={r.component} style={{ marginRight: 12 }}>
{r.component} <span className="mono">{r.tag}</span>
<span key={`${r.component}:${r.channel}`} style={{ marginRight: 12 }}>
{r.component} <span className="mono">{r.tag}</span> <span className="faint">({r.channel})</span>
</span>
))}
</span>
@@ -174,7 +175,7 @@ function Versions() {
</tr>
</thead>
<tbody>
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} /> : null}
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} update={info.data?.backend_update ? <Chip tone={info.data.backend_update.state === 'available' ? 'warn' : ''}>{info.data.backend_update.channel} · {info.data.backend_update.state === 'unavailable' ? '未安装更新器' : UPDATE_STATE[info.data.backend_update.state]?.text ?? info.data.backend_update.state}</Chip> : undefined} /> : null}
<BuildRow name="前端" kind="web" version={frontendBuild.version} tag={frontendBuild.tag} commit={frontendBuild.commit} builtAt={frontendBuild.builtAt} />
{nodes.map((n) => (
<tr key={n.id}>
+5 -3
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { NodeInfoDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types'
import { fmtAgo } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
@@ -9,7 +9,7 @@ import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
import { OsIcon } from '@shared/osIcon'
import { PageError } from '../lib'
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
@@ -155,6 +155,7 @@ export function Overview() {
// 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h)
const [params, setParams] = useSearchParams()
const ipVersion = ipVersionOf(params.get('ip_version'))
const ipv6 = useQuery(() => ipVersion === 'ipv6' ? api.get<Page<AgentIpv6Dto>>('/api/public/ipv6') : Promise.resolve(undefined), [ipVersion], 15_000)
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
const setRange = (v: OverviewRange) =>
setParams(
@@ -229,7 +230,8 @@ export function Overview() {
{data ? (
cards.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路,请在管理端配置 Agent 的 IPv6 地址' : '还没有线路'}</Empty>
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
</div>
) : (
<div className="cards">
+255
View File
@@ -21,6 +21,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/public/ipv6": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** IPv6 configuration and collector diagnostics without addresses */
get: operations["ipv6_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/public/lines/{line_id}": {
parameters: {
query?: never;
@@ -851,6 +868,41 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/backend/update": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Current channel and host-verified backend update */
get: operations["backend_update_get"];
put?: never;
/** Confirm one verified backend release before the host restarts it */
post: operations["backend_update_confirm"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backend/update/live": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Server-sent backend update notifications */
get: operations["backend_update_live"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/links": {
parameters: {
query?: never;
@@ -1011,6 +1063,13 @@ export interface components {
ipv6_host: string;
/** @default false */
ipv6_supported: boolean;
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
};
AgentIpv6Dto: {
agent_id: string;
name: string;
online: boolean;
ipv6: components["schemas"]["Ipv6DiagnosticDto"];
};
AgentPatchRequest: {
name: null | string;
@@ -1030,6 +1089,15 @@ export interface components {
/** Format: date-time */
revoked_at: unknown;
};
BackendUpdateDto: {
channel: string;
enabled: boolean;
state: string;
current_tag: null | string;
current_digest: null | string;
available: null | components["schemas"]["ReleaseDto"];
error: null | string;
};
BlocksDto: {
transport: string;
pacer: string;
@@ -1046,6 +1114,7 @@ export interface components {
BuildInfoDto: {
backend: components["schemas"]["BuildDto"];
nodes: components["schemas"]["NodeBuildDto"][];
backend_update?: null | components["schemas"]["BackendUpdateDto"];
};
ChangePasswordRequest: {
password: string;
@@ -1074,6 +1143,10 @@ export interface components {
[key: string]: null | number | string;
};
};
ConfirmBackendUpdateRequest: {
/** Format: int64 */
seq: number | string;
};
ConnectedProbeDto: {
probe_id: string;
version: null | string;
@@ -1253,6 +1326,18 @@ export interface components {
macos: string;
windows: string;
};
Ipv6DiagnosticDto: {
address_configured: null | boolean;
socket_supported: boolean;
listening: null | boolean;
has_global_address: null | boolean;
has_default_route: null | boolean;
state: string;
/** @default unknown */
network_scope: string;
host_has_global_address?: null | boolean;
host_has_default_route?: null | boolean;
};
JoinTokenDto: {
id: string;
note: null | string;
@@ -1426,6 +1511,10 @@ export interface components {
items: components["schemas"]["AgentDto"][];
next_cursor: null | string;
};
PageDtoOfAgentIpv6Dto: {
items: components["schemas"]["AgentIpv6Dto"][];
next_cursor: null | string;
};
PageDtoOfApiTokenDto: {
items: components["schemas"]["ApiTokenDto"][];
next_cursor: null | string;
@@ -1553,6 +1642,7 @@ export interface components {
revoked: boolean;
online: boolean;
connections: components["schemas"]["ProbeConnectionDto"][];
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
};
ProbeIdentityDto: {
probe_id: string;
@@ -1624,6 +1714,8 @@ export interface components {
tag: string;
version: string;
digest: string;
/** @default release */
channel: string;
};
ScoreDto: {
/** Format: double */
@@ -1836,6 +1928,35 @@ export interface operations {
};
};
};
ipv6_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageDtoOfAgentIpv6Dto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
lines_get: {
parameters: {
query?: never;
@@ -4834,6 +4955,140 @@ export interface operations {
};
};
};
backend_update_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BackendUpdateDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
backend_update_confirm: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ConfirmBackendUpdateRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Precondition Failed */
412: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
backend_update_live: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
links_list: {
parameters: {
query?: never;
+2
View File
@@ -7,6 +7,7 @@ type NarrowValue<V> = unknown extends V ? string : number extends V ? Exclude<V,
type Narrow<T> = { [K in keyof T]: NarrowValue<T[K]> }
export type AgentDto = Narrow<S['AgentDto']>
export type AgentIpv6Dto = Narrow<S['AgentIpv6Dto']>
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
export type ProbeDto = Narrow<S['ProbeDto']>
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
@@ -16,6 +17,7 @@ export type NodeInfoDto = Narrow<S['NodeInfoDto']>
export type AboutDto = Narrow<S['AboutDto']>
export type BuildDto = Narrow<S['BuildDto']>
export type BuildInfoDto = Narrow<S['BuildInfoDto']>
export type BackendUpdateDto = Narrow<S['BackendUpdateDto']>
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
export type ReleaseDto = Narrow<S['ReleaseDto']>
+11
View File
@@ -5,6 +5,17 @@ export const ipVersionOf = (value: string | null | undefined): IpVersion => valu
export const IP_VERSIONS: { value: IpVersion; label: string }[] = [{ value: 'ipv4', label: 'IPv4' }, { value: 'ipv6', label: 'IPv6' }]
export const lineIdFor = (id: string, version: IpVersion): string => id.replace(/~ipv6$/, '') + (version === 'ipv6' ? '~ipv6' : '')
export const IPV6_STATES: Record<string, string> = {
ready: 'IPv6 网络已就绪',
address_not_configured: '面板未配置 IPv6 地址',
unsupported: '运行环境不支持 IPv6',
no_global_address: '机器或容器未获得全局 IPv6 地址',
not_listening: 'Agent 只监听 IPv4,请检查 listen.bind',
no_default_route: '缺少 IPv6 默认路由',
unknown: '尚未取得 IPv6 诊断,请检查 Agent 版本',
container_network_not_ready: '宿主有 IPv6,但容器网络未就绪,请检查网络模式',
}
export function IpVersionPicker({ value, onChange }: { value: IpVersion; onChange: (value: IpVersion) => void }) {
return <Segmented options={IP_VERSIONS} value={value} onChange={onChange} />
}