feat: choose which probes link to which agents from the admin
The panel decides the probe/agent pairs and probes always dial out. A new fleet.auto_link setting connects every probe to every agent (default); with it off only the pairs chosen by hand connect, and any pair can be forced on or off. The Probes page has a link matrix with the switch and per-pair toggles.
This commit is contained in:
1 parent
d69581dcb5
commit
3a751a0f89
24 files changed
+731
-33
No files matched your search
@@ -2,12 +2,89 @@ import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-reac
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
|
||||
import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
|
||||
import { fmtAgo, fmtTime } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
/**
|
||||
* 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
|
||||
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。Probe 始终是主动拨出的一方。
|
||||
*/
|
||||
function LinkMatrix() {
|
||||
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
|
||||
const { busy, run } = useAction()
|
||||
const items = matrix.data?.items ?? []
|
||||
const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()]
|
||||
const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()]
|
||||
const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId)
|
||||
const set = async (link: LinkDto, enabled: boolean | null) => {
|
||||
await run(() => api.put(`/api/admin/links/${link.probe_id}/${link.agent_id}`, { enabled }))
|
||||
matrix.reload()
|
||||
}
|
||||
const setAuto = async (on: boolean) => {
|
||||
await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接')
|
||||
matrix.reload()
|
||||
}
|
||||
if (items.length === 0 && !matrix.loading) return null
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<b>线路</b>
|
||||
<span className="muted">Probe 主动连接 Agent。点格子手动开关;「↺」回到自动。</span>
|
||||
<label className="switch right">
|
||||
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
|
||||
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
|
||||
</label>
|
||||
</div>
|
||||
<ErrorNotice error={matrix.error} />
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Probe \ Agent</th>
|
||||
{agents.map(([id, name]) => (
|
||||
<th key={id}>{name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{probes.map(([probeId, probeName]) => (
|
||||
<tr key={probeId}>
|
||||
<td style={{ fontWeight: 600 }}>{probeName}</td>
|
||||
{agents.map(([agentId]) => {
|
||||
const link = cell(probeId, agentId)
|
||||
if (!link) return <td key={agentId} />
|
||||
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
|
||||
const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连'
|
||||
return (
|
||||
<td key={agentId}>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<button type="button" className="btn small" disabled={busy} title={link.linked ? '点一下:手动关闭' : '点一下:手动开启'} onClick={() => set(link, !link.linked)}>
|
||||
<Chip tone={tone}>{text}</Chip>
|
||||
</button>
|
||||
{link.override !== null && link.override !== undefined ? (
|
||||
<>
|
||||
<span className="faint" title="手动设置">
|
||||
手动
|
||||
</span>
|
||||
<button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => set(link, null)}>
|
||||
↺
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
|
||||
function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) {
|
||||
const json = JSON.stringify(identity, null, 2)
|
||||
@@ -116,6 +193,7 @@ export function Probes() {
|
||||
<ErrorNotice error={probes.error} />
|
||||
|
||||
<div className="stack">
|
||||
<LinkMatrix />
|
||||
{probes.data && list.length === 0 ? (
|
||||
<div className="card">
|
||||
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty>
|
||||
|
||||
@@ -1205,19 +1205,11 @@ input {
|
||||
}
|
||||
|
||||
/* 版本、tag:等宽的小字 */
|
||||
.fact .v.mono-tag,
|
||||
.probe-tag {
|
||||
.fact .v.mono-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.probe-tag {
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--app-ink) 6%, transparent);
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.build-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1227,3 +1219,35 @@ input {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 总览卡片里的线路标题:名字太长就省略号,右边只放一个短标签,不换行 */
|
||||
.line-top {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line-top .line-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-top .line-name .arrow {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.line-top .line-name .name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.line-top .line-name .flag,
|
||||
.line-top .line-name .isp {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.line-top .line-range,
|
||||
.line-top .chip {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -95,18 +95,13 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
|
||||
return (
|
||||
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
|
||||
<div className="line-top">
|
||||
<span className="line-name">
|
||||
→ {infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
|
||||
{l.probe_name}
|
||||
<span className="line-name" title={[l.probe_name, infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version].filter(Boolean).join(' · ')}>
|
||||
<span className="arrow">→</span>
|
||||
{infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
|
||||
<span className="name">{l.probe_name}</span>
|
||||
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
|
||||
</span>
|
||||
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
|
||||
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? (
|
||||
<span className="probe-tag" title="Probe 版本">
|
||||
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="faint">近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
|
||||
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
|
||||
{!l.probe_connected && l.online ? <Chip tone="warn">未连接</Chip> : <span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>}
|
||||
</div>
|
||||
<LineStrips lineId={l.id} range={range} m={m} />
|
||||
</Link>
|
||||
|
||||
@@ -83,5 +83,6 @@ 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 ?? {}),
|
||||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
del: <T>(path: string) => request<T>('DELETE', path),
|
||||
}
|
||||
@@ -851,6 +851,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/links": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Every probe/agent pair: whether it is linked, the manual override and whether it is connected */
|
||||
get: operations["links_list"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/links/{probe_id}/{agent_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** Force a link on or off, or give it back to the automatic setting (enabled: null) */
|
||||
put: operations["links_set"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/healthz": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1250,6 +1284,19 @@ export interface components {
|
||||
kinds: string[];
|
||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||
};
|
||||
LinkDto: {
|
||||
probe_id: string;
|
||||
probe_name: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
linked: boolean;
|
||||
override: null | boolean;
|
||||
connected: boolean;
|
||||
};
|
||||
LinkMatrixDto: {
|
||||
auto_link: boolean;
|
||||
items: components["schemas"]["LinkDto"][];
|
||||
};
|
||||
LocalLimitsDto: {
|
||||
/** Format: double */
|
||||
max_total_mbps: number | string;
|
||||
@@ -1594,6 +1641,9 @@ export interface components {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
SetLinkRequest: {
|
||||
enabled: null | boolean;
|
||||
};
|
||||
SettingDto: {
|
||||
key: string;
|
||||
type: string;
|
||||
@@ -4760,6 +4810,107 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
links_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"]["LinkMatrixDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
links_set: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
probe_id: string;
|
||||
agent_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SetLinkRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LinkDto"];
|
||||
};
|
||||
};
|
||||
/** @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 Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
health_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -19,6 +19,8 @@ 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 LinkDto = Narrow<S['LinkDto']>
|
||||
export type LinkMatrixDto = Narrow<S['LinkMatrixDto']>
|
||||
export type OverviewDto = Narrow<S['OverviewDto']>
|
||||
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
|
||||
export type SeriesDto = Narrow<S['SeriesDto']>
|
||||
|
||||
@@ -143,6 +143,7 @@ export function translateCode(code: string): string | undefined {
|
||||
|
||||
const SETTINGS: Record<string, string> = {
|
||||
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
|
||||
'fleet.auto_link': '所有 Probe 自动连接所有 Agent(关闭后只连在「Probe → 线路」里手动选中的)',
|
||||
'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间',
|
||||
'retention.raw_days': '持续任务的原始数据块保留多少天',
|
||||
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
|
||||
|
||||
Reference in new issue
Block a user