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}>