feat: update controls in the admin, release token scope, build info in CI images

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:56:56 +08:00
1 parent 22a98f2eb0
commit 7a71bf57f9
17 files changed
+470 -141

No files matched your search

+113 -113
View File
@@ -1140,6 +1140,116 @@
}
}
},
"/api/admin/releases": {
"post": {
"tags": [
"admin-build"
],
"summary": "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens",
"operationId": "releases_publish",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishReleaseRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
},
"get": {
"tags": [
"admin-build"
],
"summary": "The latest signed release manifest of each component",
"operationId": "releases_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PageDtoOfReleaseDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/api/admin/auth/logout": {
"post": {
"tags": [
@@ -3732,116 +3842,6 @@
}
}
},
"/api/admin/releases": {
"get": {
"tags": [
"admin-build"
],
"summary": "The latest signed release manifest of each component",
"operationId": "releases_list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PageDtoOfReleaseDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
},
"post": {
"tags": [
"admin-build"
],
"summary": "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature)",
"operationId": "releases_publish",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishReleaseRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/api/admin/nodes/{id}/update": {
"post": {
"tags": [
@@ -7421,6 +7421,9 @@
{
"name": "admin-auth"
},
{
"name": "admin-build"
},
{
"name": "admin-agents"
},
@@ -7448,9 +7451,6 @@
{
"name": "admin-users"
},
{
"name": "admin-build"
},
{
"name": "ops"
},
@@ -19,13 +19,18 @@ public static class BuildEndpoints
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
group.MapGet("/releases", ListReleasesAsync)
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
group.MapPost("/releases", PublishReleaseAsync)
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature)", Tag)
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409);
}
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
public static void MapPublish(RouteGroupBuilder group)
{
group.MapPost("/releases", PublishReleaseAsync)
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, CancellationToken ct)
{
var items = (await nodes.ListAsync(ct))
@@ -103,6 +103,8 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
@@ -62,7 +62,7 @@ public static class AdminGuard
Principal principal = http.GetPrincipal();
if (!principal.IsAdmin)
{
return principal.CanRead
return principal.Scope != ApiScope.None
? Problems.Create(http, MonitorErrors.Forbidden, "the token scope does not allow this operation")
: Problems.Create(http, MonitorErrors.Unauthorized, "authentication is required");
}
@@ -80,6 +80,13 @@ public static class AdminGuard
}
}
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
public static class ReleaseGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
}
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
public static class PublicGuard
{
@@ -7,12 +7,13 @@ public enum PrincipalKind
Token,
}
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理。</summary>
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
public enum ApiScope
{
None = 0,
Read = 1,
Admin = 2,
Release = 1,
Read = 2,
Admin = 3,
}
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
@@ -22,6 +23,8 @@ public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserI
public bool CanRead => Scope >= ApiScope.Read;
public bool IsAdmin => Scope >= ApiScope.Admin;
public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin;
}
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
@@ -77,7 +77,7 @@ public sealed partial class AuthService
await _store.TouchApiTokenAsync(token.Id, now, ct);
}
ApiScope scope = token.Scope == "admin" ? ApiScope.Admin : ApiScope.Read;
ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read };
return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id);
}
@@ -135,9 +135,9 @@ public sealed partial class AuthService
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
}
if (scope is not ("read" or "admin"))
if (scope is not ("read" or "admin" or "release"))
{
return ValidationError.Fail<CreatedApiToken>("scope", "must be read or admin");
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
}
string plaintext = TokenHash.NewToken("mon");
@@ -67,6 +67,40 @@ public sealed class UpdateTests
Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden });
}
[Fact]
public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat()
{
async Task<string> NewTokenAsync(string scope)
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}
HttpClient Client(string token)
{
var client = new HttpClient { BaseAddress = _backend.BaseAddress };
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
return client;
}
long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 50;
using HttpClient release = Client(await NewTokenAsync("release"));
using HttpClient read = Client(await NewTokenAsync("read"));
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" });
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }));
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body);
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
// release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = "MEUC" }))).StatusCode);
}
[Fact]
public async Task UpdateRequest_ReachesTheNode_AndItsReportedStatusShowsInBuildInfo()
{
+1
View File
@@ -49,6 +49,7 @@ local_image="localhost/monitor-$component:main-amd64-check"
python3 "$root/ci/stage-node-image.py" "$component" "$context"
buildah bud --layers --network host --tls-verify=true --arch amd64 \
--build-arg APP_VERSION=main \
--build-arg "MONITOR_TAG=$dev_tag" --build-arg "MONITOR_COMMIT=${CI_COMMIT_SHA:0:10}" --build-arg "MONITOR_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-f "$context/Containerfile" \
-t "$local_image" "$context"
+5
View File
@@ -51,6 +51,11 @@ $output = Join-Path $root ".release\publish\$RuntimeIdentifier\$Component"
$buildRoot = Join-Path $root ".release\build\$RuntimeIdentifier-$Component"
$null = New-Item -ItemType Directory -Force -Path $output
# 构建信息:程序里显示的 tag、提交、构建时间
$env:MONITOR_TAG = if ($env:CI_COMMIT_TAG) { $env:CI_COMMIT_TAG } else { "main-$($env:CI_COMMIT_SHA.Substring(0, 10))" }
$env:MONITOR_COMMIT = $env:CI_COMMIT_SHA.Substring(0, 10)
$env:MONITOR_BUILT_AT = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
Push-Location $backend
try {
& dotnet publish $project `
+5
View File
@@ -45,6 +45,11 @@ build_root="$root/.release/build/linux-$arch-backend"
container_context="$root/.release/container/linux-$arch"
mkdir -p "$output" "$build_root" "$container_context/app" "$container_context/data"
# 构建信息:tag 发布用 tag,main 构建用 main-<提交>
export MONITOR_TAG="${MONITOR_TAG:-${tag:-main-${CI_COMMIT_SHA:0:10}}}"
export MONITOR_COMMIT="${MONITOR_COMMIT:-${CI_COMMIT_SHA:0:10}}"
export MONITOR_BUILT_AT="${MONITOR_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
(cd "$root/backend" && dotnet publish src/Monitor.Backend \
-m:3 -c Release --no-self-contained \
-p:DebugType=none -p:DebugSymbols=false \
+1 -1
View File
@@ -130,7 +130,7 @@
| GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.monitor.rawchunk`);过期返回 410 `raw_expired` |
| GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 |
| GET | `/api/admin/analysis/recompute` | 指标重算进度 |
| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read` 或 `admin`;明文只显示一次 |
| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read`、`admin` 或 `release`(只能登记发布清单,给 CI 用,什么都读不了);明文只显示一次 |
| 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?}` |
+1 -1
View File
@@ -214,7 +214,7 @@ CI 构建并推镜像(Zot)──► ci/release-manifest.sh:对镜像 diges
- **安装更新器**(每个容器节点一次,root):`packaging/update/install-updater.sh agent --data-dir /opt/monitor/etc --image-repo registry.oeo.one/monitor/monitor-agent --unit monitor-agent-container.service [--policy auto] [--authfile <Registry 认证文件>]`。它装好 `monitor-update` 脚本和发布公钥,写 `/etc/monitor-update/<组件>.conf`,启用两个 systemd 路径单元。容器单元要用 `EnvironmentFile=/etc/monitor-update/<组件>.image` 和 `${IMAGE}` 启动容器。
- **镜像仓库、服务名、公钥都在本机配置里**,清单里写什么都不会改变拉取哪个仓库;清单被篡改、签名对不上、组件或仓库不符、`seq` 不比已安装的大(重放旧清单),都会被拒绝,状态是 `invalid`。
- **业务进程不能改二进制,也不执行命令**:它只能往 `update/` 里放清单和请求;状态在 root 拥有的 `update-status/` 里(只读)。
- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,管理员权限,用来登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。
- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,作用域 `release`,只能登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。
- **发布公钥**在 `packaging/update/release-key.pub.pem`;私钥不入库,请离线备份。更换密钥:节点上换新公钥之后再用新私钥发布。
- 纯二进制的安装方式(`fleet-updater`、`fleet-release`,见 [fleet/update.md](fleet/update.md))还没有实现;容器方式优先。
@@ -1,17 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src/backend
# 构建信息:CI 通过 --build-arg 传入;不传就是空
ARG MONITOR_TAG=
ARG MONITOR_COMMIT=
ARG MONITOR_BUILT_AT=
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
COPY restore/backend/ ./
# restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」
RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts \
&& dotnet restore fleet/tools/fleet-enroll -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts
COPY source/backend/ ./
# 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效)
ARG MONITOR_TAG=
ARG MONITOR_COMMIT=
ARG MONITOR_BUILT_AT=
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
RUN dotnet publish agent/src/Monitor.Agent -m:3 -c Release -r linux-x64 \
--self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \
-p:UseAppHost=true -p:StripSymbols=true \
@@ -1,16 +1,16 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src/backend
# 构建信息:CI 通过 --build-arg 传入;不传就是空
ARG MONITOR_TAG=
ARG MONITOR_COMMIT=
ARG MONITOR_BUILT_AT=
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
COPY restore/backend/ ./
# restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」
RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts
COPY source/backend/ ./
# 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效)
ARG MONITOR_TAG=
ARG MONITOR_COMMIT=
ARG MONITOR_BUILT_AT=
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
RUN dotnet publish probe/src/Monitor.Probe -m:3 -c Release -r linux-x64 \
--self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \
-p:UseAppHost=true -p:StripSymbols=true \
+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']>