feat: update controls in the admin, release token scope, build info in CI images [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

The versions page shows each node's update state with update buttons, API
tokens get a release scope that can only register releases (used by CI), and
the CI image and release builds now pass the tag, commit and build time.
This commit is contained in:
justme committed 2026-10-06 22:57:04 +08:00
1 parent 993dd5c41c
commit 251e0cee99
17 files changed
+470 -141

No files matched your search

+62 -5
View File
@@ -1,7 +1,7 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
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'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
@@ -95,8 +95,36 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { nam
)
}
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */
/** 节点宿主机上的更新器报告的状态;没装更新器(二进制安装、旧版本)的节点没有。 */
const UPDATE_STATE: Record<string, { text: string; tone: '' | 'ok' | 'warn' | 'bad' }> = {
up_to_date: { text: '已是最新', tone: 'ok' },
available: { text: '有新版本', tone: 'warn' },
applying: { text: '更新中…', tone: 'warn' },
failed: { text: '更新失败', tone: 'bad' },
rolled_back: { text: '已回滚', tone: 'bad' },
invalid: { text: '清单无效', tone: 'bad' },
}
function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) {
if (!update) return <span className="faint">—</span>
const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const }
return (
<span className="row" style={{ gap: 8 }} title={update.error ?? undefined}>
<Chip tone={s.tone}>{s.text}</Chip>
{update.state === 'available' ? <span className="mono muted">{update.available_tag}</span> : null}
{update.state === 'available' ? (
<button type="button" className="btn small primary" disabled={busy} onClick={onUpdate}>
更新
</button>
) : null}
</span>
)
}
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的,以及 CI 发布的最新签名清单和各节点的更新状态。 */
function Versions() {
const { busy, run } = useAction()
const releases = useQuery(() => api.get<Page<ReleaseDto>>('/api/admin/releases'), [], 30_000)
const info = useQuery(() => api.get<BuildInfoDto>('/api/admin/build-info'), [], 15_000)
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 30_000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 30_000)
@@ -105,9 +133,33 @@ function Versions() {
const nodes = info.data?.nodes ?? []
// 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到)
const mismatch = (v?: string | null) => Boolean(v && back && v !== back.version)
const updatable = nodes.filter((n) => n.update?.state === 'available')
const requestUpdate = async (ids: string[]) => {
for (const id of ids) {
await run(() => api.post(`/api/admin/nodes/${id}/update`), '已请求更新')
}
info.reload()
}
return (
<div className="card">
<ErrorNotice error={info.error} />
<ErrorNotice error={info.error ?? releases.error} />
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">
最新发布:
{(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>
))}
</span>
{updatable.length > 0 ? (
<button type="button" className="btn primary right" disabled={busy} onClick={() => requestUpdate(updatable.map((n) => n.id))}>
全部更新({updatable.length})
</button>
) : null}
</div>
<table className="table">
<thead>
<tr>
@@ -118,6 +170,7 @@ function Versions() {
<th>提交</th>
<th>构建时间</th>
<th>系统</th>
<th>更新</th>
</tr>
</thead>
<tbody>
@@ -136,6 +189,9 @@ function Versions() {
<td className="mono">{n.commit || <span className="faint">—</span>}</td>
<td className="muted">{fmtBuiltAt(n.built_at) || <span className="faint">—</span>}</td>
<td className="muted">{[n.os_name, n.arch].filter(Boolean).join(' / ') || <span className="faint">—</span>}</td>
<td>
<UpdateCell update={n.update} busy={busy} onUpdate={() => requestUpdate([n.id])} />
</td>
</tr>
))}
</tbody>
@@ -143,6 +199,7 @@ function Versions() {
{nodes.length === 0 && !info.loading ? <Empty>还没有节点上报过版本</Empty> : null}
<p className="muted" style={{ marginTop: 12 }}>
版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
「更新」一列来自节点宿主机上的更新器:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。
</p>
</div>
)
@@ -153,7 +210,7 @@ function Tokens() {
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [scope, setScope] = useState<'read' | 'admin'>('read')
const [scope, setScope] = useState<'read' | 'admin' | 'release'>('read')
const [created, setCreated] = useState<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto>()
return (
@@ -237,7 +294,7 @@ function Tokens() {
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="权限">
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }]} value={scope} onChange={setScope} />
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }, { value: 'release' as const, label: 'release(只登记发布,给 CI)' }]} value={scope} onChange={setScope} />
</Field>
</div>
</Dialog>
+209 -1
View File
@@ -310,6 +310,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/releases": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The latest signed release manifest of each component */
get: operations["releases_list"];
put?: never;
/** Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens */
post: operations["releases_publish"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/logout": {
parameters: {
query?: never;
@@ -806,7 +824,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Version, tag, commit and build time of the backend and every agent and probe */
/** Version, tag, commit, build time and update state of the backend and every agent and probe */
get: operations["build_info_get"];
put?: never;
post?: never;
@@ -816,6 +834,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/nodes/{id}/update": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Ask an online agent or probe to install the release it has been offered */
post: operations["nodes_update"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": {
parameters: {
query?: never;
@@ -1277,6 +1312,7 @@ export interface components {
tag: null | string;
commit: null | string;
built_at: null | string;
update?: null | components["schemas"]["NodeUpdateDto"];
};
NodeInfoDto: {
id: string;
@@ -1290,6 +1326,15 @@ export interface components {
version?: null | string;
tag?: null | string;
};
NodeUpdateDto: {
state: string;
current_digest: null | string;
available_tag: null | string;
available_version: null | string;
error: null | string;
/** Format: date-time */
at: unknown;
};
OverviewDto: {
range: string;
/** Format: date-time */
@@ -1367,6 +1412,10 @@ export interface components {
items: components["schemas"]["RawChunkMetaDto"][];
next_cursor: null | string;
};
PageDtoOfReleaseDto: {
items: components["schemas"]["ReleaseDto"][];
next_cursor: null | string;
};
PageDtoOfSettingDto: {
items: components["schemas"]["SettingDto"][];
next_cursor: null | string;
@@ -1465,6 +1514,10 @@ export interface components {
[key: string]: string[];
};
};
PublishReleaseRequest: {
manifest: null | string;
signature: null | string;
};
RawChunkMetaDto: {
chunk_id: string;
agent_id: string;
@@ -1500,6 +1553,14 @@ export interface components {
id: string;
reason: string;
};
ReleaseDto: {
component: string;
/** Format: int64 */
seq: number | string;
tag: string;
version: string;
digest: string;
};
ScoreDto: {
/** Format: double */
score: null | number | string;
@@ -2473,6 +2534,104 @@ export interface operations {
};
};
};
releases_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageDtoOfReleaseDto"];
};
};
/** @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"];
};
};
};
};
releases_publish: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PublishReleaseRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReleaseDto"];
};
};
/** @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 Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_logout: {
parameters: {
query?: never;
@@ -4552,6 +4711,55 @@ export interface operations {
};
};
};
nodes_update: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @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 Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
health_get: {
parameters: {
query?: never;
+2
View File
@@ -17,6 +17,8 @@ export type AboutDto = Narrow<S['AboutDto']>
export type BuildDto = Narrow<S['BuildDto']>
export type BuildInfoDto = Narrow<S['BuildInfoDto']>
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
export type ReleaseDto = Narrow<S['ReleaseDto']>
export type OverviewDto = Narrow<S['OverviewDto']>
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
export type SeriesDto = Narrow<S['SeriesDto']>