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:
1 parent
10634bdd80
commit
14b0de3544
95 files changed
+5524
-528
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']>
|
||||
|
||||
Reference in new issue
Block a user