Replace Probe pages with directed Agent links, shared deployment controls and unified display data. Add node dragging, canvas pan and zoom, saved layouts, and per-direction link controls.
171 lines
7.6 KiB
TypeScript
171 lines
7.6 KiB
TypeScript
import { KeyRound } from 'lucide-react'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { CapabilityCatalogDto } from '@shared/api/types'
|
|
import { Chip } from '@shared/ui'
|
|
|
|
/** 能力的中文名和说明。目录(分组、依赖、预设)来自后端,这里只管显示文字。 */
|
|
const NAMES: Record<string, [string, string]> = {
|
|
'public.read': ['查看展示数据', '看隐藏线路;站点私有时看展示页'],
|
|
'fleet.read': ['查看节点', 'Agent、线路、加入代码'],
|
|
'fleet.write': ['修改节点', '改名、标签、可见性、地址、线路开关、断开会话'],
|
|
'fleet.enroll': ['添加节点', '生成加入代码、批准新节点'],
|
|
'fleet.revoke': ['吊销或删除节点', '破坏性操作'],
|
|
'tasks.read': ['查看任务', '任务模板、覆盖、测试记录'],
|
|
'tasks.write': ['修改任务', '创建、修改、删除任务;发起或取消测试'],
|
|
'data.read': ['读取原始数据', '原始块、导出'],
|
|
'data.write': ['维护数据', '重算分析、清理'],
|
|
'data.export': ['批量导出数据', '预留:按线路和时间批量拉取,带配额'],
|
|
'settings.read': ['查看设置', '站点设置、保留策略'],
|
|
'settings.write': ['修改设置', '站点设置、保留策略、重启计划'],
|
|
'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'],
|
|
'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'],
|
|
'keys.read': ['查看 API 令牌', ''],
|
|
'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'],
|
|
'accounts.manage': ['管理账户', '面板用户、密码'],
|
|
'events.read': ['查看事件', '节点上下线等事件流'],
|
|
'audit.read': ['查看审计日志', ''],
|
|
'ops.metrics': ['读取 /metrics', 'Prometheus 指标'],
|
|
'webhooks.read': ['查看 webhook', '预留:外部回调订阅'],
|
|
'webhooks.manage': ['管理 webhook', '预留:创建、修改、删除回调订阅,触发测试回调'],
|
|
}
|
|
|
|
const GROUPS: Record<string, string> = {
|
|
public: '展示',
|
|
fleet: '节点',
|
|
tasks: '任务',
|
|
data: '数据',
|
|
settings: '设置',
|
|
update: '更新与发布',
|
|
keys: 'API 令牌',
|
|
accounts: '账户',
|
|
ops: '运维与审计',
|
|
integrations: '集成(预留)',
|
|
}
|
|
|
|
const PRESETS: Record<string, string> = {
|
|
'read-only': '只读',
|
|
operations: '运维',
|
|
enroll: '接入',
|
|
'ci-release': 'CI 发布',
|
|
all: '全部',
|
|
}
|
|
|
|
/** 复选框:支持半选(indeterminate)。 */
|
|
function Check({ checked, partial, disabled, onChange, label }: { checked: boolean; partial?: boolean; disabled?: boolean; onChange: (on: boolean) => void; label: React.ReactNode }) {
|
|
const ref = useRef<HTMLInputElement>(null)
|
|
useEffect(() => {
|
|
if (ref.current) ref.current.indeterminate = !!partial && !checked
|
|
}, [partial, checked])
|
|
return (
|
|
<label className="switch">
|
|
<input ref={ref} type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
|
|
{label}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 能力选择器:分组复选框 + 预设 + 全选/组内全选(带半选)/反选 + 搜索。
|
|
* `mine` 是创建者自己拥有的能力:不在里面的灰色不可选(不能授予自己没有的);写蕴含读,勾选写时读会一起被勾上。
|
|
*/
|
|
export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog: CapabilityCatalogDto; mine: ReadonlySet<string>; value: ReadonlySet<string>; onChange: (next: Set<string>) => void }) {
|
|
const [query, setQuery] = useState('')
|
|
const info = useMemo(() => new Map(catalog.groups.flatMap((g) => g.capabilities.map((c) => [c.id, c] as const))), [catalog])
|
|
const selectable = (id: string) => mine.has(id)
|
|
|
|
// 勾选一项:补上它蕴含的;取消一项:同时取消依赖它的(写依赖读)
|
|
const closure = (ids: Iterable<string>): Set<string> => {
|
|
const out = new Set<string>()
|
|
const add = (id: string) => {
|
|
if (out.has(id) || !selectable(id)) return
|
|
out.add(id)
|
|
info.get(id)?.implies.forEach(add)
|
|
}
|
|
for (const id of ids) add(id)
|
|
return out
|
|
}
|
|
const set = (ids: Iterable<string>) => onChange(closure(ids))
|
|
const toggle = (id: string, on: boolean) => {
|
|
if (on) return set([...value, id])
|
|
const next = new Set(value)
|
|
next.delete(id)
|
|
// 依赖被取消的项一起取消
|
|
let changed = true
|
|
while (changed) {
|
|
changed = false
|
|
for (const x of next) if (info.get(x)?.implies.some((i) => !next.has(i))) { next.delete(x); changed = true }
|
|
}
|
|
onChange(next)
|
|
}
|
|
|
|
const needle = query.trim().toLowerCase()
|
|
const visible = (id: string) => !needle || id.includes(needle) || (NAMES[id]?.[0] ?? '').includes(needle) || (NAMES[id]?.[1] ?? '').includes(needle)
|
|
const all = [...info.keys()].filter(selectable)
|
|
const allOn = all.length > 0 && all.every((id) => value.has(id))
|
|
|
|
return (
|
|
<div>
|
|
<div className="cap-toolbar stack" style={{ gap: 8 }}>
|
|
<div className="row wrap" style={{ gap: 6 }}>
|
|
<span className="muted">预设:</span>
|
|
{catalog.presets.map((p) => (
|
|
<button key={p.id} type="button" className="btn small" onClick={() => set(p.capabilities.filter(selectable))}>
|
|
{PRESETS[p.id] ?? p.id}
|
|
</button>
|
|
))}
|
|
<input className="input" style={{ maxWidth: 220, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
</div>
|
|
<div className="row wrap" style={{ gap: 12 }}>
|
|
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
|
|
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
|
|
反选
|
|
</button>
|
|
<span className="muted">已选 {value.size} / {all.length} 项</span>
|
|
</div>
|
|
</div>
|
|
<div className="cap-grid">
|
|
{catalog.groups.map((g) => {
|
|
const rows = g.capabilities.filter((c) => visible(c.id))
|
|
if (rows.length === 0) return null
|
|
const own = g.capabilities.filter((c) => selectable(c.id))
|
|
const on = own.filter((c) => value.has(c.id))
|
|
return (
|
|
<div key={g.id} className="cap-group">
|
|
<Check
|
|
checked={own.length > 0 && on.length === own.length}
|
|
partial={on.length > 0}
|
|
disabled={own.length === 0}
|
|
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
|
|
label={<b>{GROUPS[g.id] ?? g.id}</b>}
|
|
/>
|
|
<div style={{ marginTop: 6 }}>
|
|
{rows.map((c) => (
|
|
<div key={c.id} className="cap-row">
|
|
<div className="row" style={{ gap: 8 }}>
|
|
<Check checked={value.has(c.id)} disabled={!selectable(c.id)} onChange={(next) => toggle(c.id, next)} label={NAMES[c.id]?.[0] ?? c.id} />
|
|
{c.trust ? (
|
|
<Chip tone="warn">
|
|
<KeyRound size={12} /> 信任类
|
|
</Chip>
|
|
) : null}
|
|
{c.reserved ? <Chip>预留</Chip> : null}
|
|
</div>
|
|
<div className="cap-meta faint">
|
|
<span className="mono">{c.id}</span>
|
|
{NAMES[c.id]?.[1] ?? ''}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function capabilityName(id: string): string {
|
|
return NAMES[id]?.[0] ?? id
|
|
}
|