feat: node info, build info and acrylic web theme

Squash of recent dev work: return_session login, backend switcher, line
detail timeline, overview strips, node platform/country/ISP info with flags
and icons, build info (version, tag, time) behind site.show_build, and the
acrylic display theme.
This commit is contained in:
justme committed 2026-10-06 21:24:25 +08:00
1 parent 10634bdd80
commit 14b0de3544
95 files changed
+5524 -528

No files matched your search

+22
View File
@@ -57,6 +57,28 @@ async function request<T>(method: string, path: string, body?: unknown, init?: R
return data as T
}
/**
* 在另一个后端上用用户名和密码登录:不设 Cookie(跨域用不了),把会话令牌放在响应里返回(`return_session`),
* 调用方存进浏览器,之后每个请求带上。
*/
export async function loginTo(base: string, username: string, password: string): Promise<string> {
const response = await fetch(`${base}/api/admin/auth/login`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ username, password, return_session: true }),
})
const text = await response.text()
const data = text ? (JSON.parse(text) as unknown) : undefined
if (!response.ok) {
const problem = (data ?? {}) as { code?: string; detail?: string; title?: string }
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText)
}
const token = (data as { session_token?: string | null } | undefined)?.session_token
if (!token) throw new ApiError(502, 'no_session', '这个后端的版本还不支持跨域登录,请先升级后端')
return token
}
export const api = {
get: <T>(path: string, query?: Query, init?: RequestInit) => request<T>('GET', withQuery(path, query), undefined, init),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
+200 -3
View File
@@ -174,6 +174,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/public/nodes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Display info of the visible agents and probes: system, architecture, country and ISP (never an address) */
get: operations["nodes_list"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/public/about": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The backend's version, tag and build time, if the site shows build info */
get: operations["about_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/public/catalog/kinds": {
parameters: {
query?: never;
@@ -442,7 +476,7 @@ export interface paths {
get?: never;
put?: never;
post?: never;
/** Revoke a join token */
/** Revoke a join token; with purge=true delete the record for good */
delete: operations["join_tokens_revoke"];
options?: never;
head?: never;
@@ -765,6 +799,23 @@ export interface paths {
patch: operations["users_update"];
trace?: never;
};
"/api/admin/build-info": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Version, tag, commit and build time of the backend and every agent and probe */
get: operations["build_info_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": {
parameters: {
query?: never;
@@ -854,6 +905,10 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AboutDto: {
show_build: boolean;
backend: null | components["schemas"]["BuildDto"];
};
AgentDto: {
id: string;
name: string;
@@ -915,6 +970,16 @@ export interface components {
tcp_port: number | string;
spki: string;
};
BuildDto: {
version: string;
tag: null | string;
commit: null | string;
built_at: null | string;
};
BuildInfoDto: {
backend: components["schemas"]["BuildDto"];
nodes: components["schemas"]["NodeBuildDto"][];
};
ChangePasswordRequest: {
password: string;
};
@@ -986,7 +1051,7 @@ export interface components {
CreatedJoinTokenDto: {
token: components["schemas"]["JoinTokenDto"];
plaintext: string;
install_command: string;
install_commands: components["schemas"]["InstallCommandsDto"];
};
DimensionDto: {
id: string;
@@ -1112,6 +1177,11 @@ export interface components {
[key: string]: (null | number | string)[];
};
};
InstallCommandsDto: {
linux: string;
macos: string;
windows: string;
};
JoinTokenDto: {
id: string;
note: null | string;
@@ -1164,6 +1234,7 @@ export interface components {
LoginRequest: {
username: string;
password: string;
return_session: null | boolean;
};
LoginResponse: {
user_id: string;
@@ -1171,6 +1242,7 @@ export interface components {
csrf_token: string;
/** Format: date-time */
expires_at: unknown;
session_token: null | string;
};
MeDto: {
kind: string;
@@ -1202,6 +1274,29 @@ export interface components {
version: number | string;
needs: string[];
};
NodeBuildDto: {
id: string;
kind: string;
os: null | string;
os_name: null | string;
arch: null | string;
version: null | string;
tag: null | string;
commit: null | string;
built_at: null | string;
};
NodeInfoDto: {
id: string;
kind: string;
os: null | string;
os_name: null | string;
arch: null | string;
country: null | string;
isp_key: null | string;
isp_name: null | string;
version?: null | string;
tag?: null | string;
};
OverviewDto: {
range: string;
/** Format: date-time */
@@ -1259,6 +1354,10 @@ export interface components {
items: components["schemas"]["ModelDto"][];
next_cursor: null | string;
};
PageDtoOfNodeInfoDto: {
items: components["schemas"]["NodeInfoDto"][];
next_cursor: null | string;
};
PageDtoOfPlanDto: {
items: components["schemas"]["PlanDto"][];
next_cursor: null | string;
@@ -2126,6 +2225,64 @@ export interface operations {
};
};
};
nodes_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"]["PageDtoOfNodeInfoDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
about_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"]["AboutDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
catalog_kinds: {
parameters: {
query?: never;
@@ -2902,7 +3059,9 @@ export interface operations {
};
join_tokens_revoke: {
parameters: {
query?: never;
query?: {
purge?: boolean;
};
header?: never;
path: {
id: string;
@@ -4361,6 +4520,44 @@ export interface operations {
};
};
};
build_info_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"]["BuildInfoDto"];
};
};
/** @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"];
};
};
};
};
health_get: {
parameters: {
query?: never;
+64 -4
View File
@@ -41,17 +41,65 @@ export function getBase(): string {
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
}
export interface SavedSource {
base: string
/** 在这个后端登录之后拿到的会话(只存在浏览器里);空 = 还没登录。 */
token: string
}
// v2:以前保存的是手填的管理 Token,现在是登录后拿到的会话,旧的不再使用
const listKey = () => `monitor.sources.v2.${app}`
function readList(): SavedSource[] {
try {
const parsed = JSON.parse(read(listKey()) || '[]') as unknown
return Array.isArray(parsed) ? (parsed as SavedSource[]).filter((x) => typeof x?.base === 'string' && x.base !== '') : []
} catch {
return []
}
}
/** 保存过的后端(每个前端各存各的登录会话);当前连接的那个一定在里面。不含这个页面自己所在的后端。 */
export function listSources(): SavedSource[] {
const list = readList()
const active = getBase()
if (active && !list.some((x) => x.base === active)) list.push({ base: active, token: '' })
return list
}
function writeList(list: SavedSource[]) {
write(listKey(), JSON.stringify(list))
}
/** 当前连接的后端的登录会话;这个页面自己所在的后端用 Cookie,没有。 */
export function getToken(): string {
return read(`monitor.api_token.${app}`)
const active = getBase()
return active ? (readList().find((x) => x.base === active)?.token ?? '') : ''
}
/** 保存(或更新)一个后端和它的登录会话,并连过去。地址留空 = 这个页面自己所在的后端。 */
export function setSource(base: string, token: string) {
write(BASE_KEY, normalizeBase(base))
write(`monitor.api_token.${app}`, token.trim())
const next = normalizeBase(base)
if (next) writeList([...readList().filter((x) => x.base !== next), { base: next, token }])
write(BASE_KEY, next)
}
/** 切换到已经保存过的后端(空串 = 这个页面自己所在的后端)。 */
export function activateSource(base: string) {
write(BASE_KEY, normalizeBase(base))
}
/** 忘掉一个保存过的后端;正连着的话回到这个页面自己所在的后端。 */
export function removeSource(base: string) {
writeList(readList().filter((x) => x.base !== base))
if (getBase() === base) write(BASE_KEY, '')
}
/** 只改当前连接的后端的登录会话(过期、退出时清掉)。 */
export function setToken(token: string) {
write(`monitor.api_token.${app}`, token.trim())
const active = getBase()
if (!active) return
writeList([...readList().filter((x) => x.base !== active), { base: active, token }])
}
export const isRemote = () => getBase() !== ''
@@ -66,6 +114,18 @@ export function authHeaders(): Record<string, string> {
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
}
/**
* 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。
*/
export async function pingBackend(): Promise<boolean> {
try {
await fetch(resolveUrl('/healthz'), { mode: 'no-cors', cache: 'no-store' })
return true
} catch {
return false
}
}
export interface LiveSource {
onopen: (() => void) | null
onerror: (() => void) | null
+5
View File
@@ -12,6 +12,11 @@ export type ProbeDto = Narrow<S['ProbeDto']>
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
export type ProbeIdentityDto = Narrow<S['ProbeIdentityDto']>
export type LineDto = Narrow<S['LineDto']>
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 NodeBuildDto = Narrow<S['NodeBuildDto']>
export type OverviewDto = Narrow<S['OverviewDto']>
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
export type SeriesDto = Narrow<S['SeriesDto']>