ui(managed-ne): replace import hint with help popover

Move bulk import and bastion/jump notes into a toolbar help popover to keep the action bar clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-05 15:00:01 +08:00
parent 7d74e7b3f4
commit 81c028949b
5 changed files with 114 additions and 78 deletions

View file

@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from "react";
type Props = {
text: string;
ariaLabel: string;
align?: "start" | "end";
};
export function HelpHint({ text, ariaLabel }: Props) {
export function HelpHint({ text, ariaLabel, align = "start" }: Props) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLSpanElement>(null);
@ -26,7 +27,7 @@ export function HelpHint({ text, ariaLabel }: Props) {
}, [open]);
return (
<span className="help-hint" ref={rootRef}>
<span className={`help-hint${align === "end" ? " help-hint--align-end" : ""}`} ref={rootRef}>
<button
type="button"
className="help-hint__trigger"

View file

@ -169,8 +169,19 @@ const en = {
connectDetailTitle: "Connectivity test log",
connectDetailEmpty:
"No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).",
importHint:
"Import template includes optional hop columns (hop_enabled, etc.), or import NEs first then batch-add proxy. Password may be empty for bastion-managed targets.",
help:
"[Bulk import]\n" +
"· Required columns: device_type, ip, username, port, protocol, name, vendor. Download the template first.\n" +
"· password may be empty (required for direct login; optional for bastion-managed or batch proxy later).\n" +
"· Optional hop columns: hop_enabled, hop_vendor, hop_host, hop_port, hop_username, hop_password, hop_target_auth_mode, hop_command_template.\n" +
"· Or import NEs first, select rows, then use Batch add proxy.\n\n" +
"[Jump / bastion]\n" +
"· Bastion SSH username template: {hop_user}@{target_user}@{target_ip}@{hop_host}; target account = NE Username.\n" +
"· Bastion-managed: set Jump password (Vault); target password optional. Manual mode needs target password.\n" +
"· JumpServer/CBH often use port 2222; some sites use 22.\n\n" +
"[Connectivity / edit]\n" +
"· NETX_CREDENTIAL_SECRET_KEY required to store passwords.\n" +
"· Leave password blank on edit to keep unchanged; open Details after failed connect-test for hop context (no secrets).",
importResult: {
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
},
@ -187,8 +198,6 @@ const en = {
deleted: "Network element deleted",
passwordRequired: "Password is required",
passwordOptional: "leave blank to keep unchanged",
passwordHint:
"Required for direct login; leave empty when bastion manages target credentials. Use batch proxy later.",
},
hop: {
sectionTitle: "Jump host / proxy",

View file

@ -167,8 +167,19 @@ const zh = {
connectDetail: "详情",
connectDetailTitle: "连通性测试日志",
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。",
importHint:
"导入模板含可选跳板列(hop_enabled 等);也可先导入网元再批量添加代理。密码可留空(堡垒机托管场景)。",
help:
"【批量导入】\n" +
"· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" +
"· password 可留空(直连需填;堡垒机托管或后续批量添加代理时可空)。\n" +
"· 可选跳板列:hop_enabled、hop_vendor、hop_host、hop_port、hop_username、hop_password、hop_target_auth_mode、hop_command_template。\n" +
"· 也可先导入网元,勾选后点「批量添加代理」统一配置跳板。\n\n" +
"【跳板 / 堡垒机】\n" +
"· 堡垒机 SSH 用户名模板:{hop_user}@{target_user}@{target_ip}@{hop_host};目标账号填网元「用户名」。\n" +
"· 堡垒机托管时填「跳板密码」(Vault 密码),目标密码可留空;手动模式需填目标密码。\n" +
"· JumpServer/CBH 常用跳板端口 2222,部分现场为 22。\n\n" +
"【连通性 / 编辑】\n" +
"· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY。\n" +
"· 编辑时密码留空表示不修改;连通性失败请点「详情」查看 hop 上下文(不含密码)。",
importResult: {
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
},
@ -185,7 +196,6 @@ const zh = {
deleted: "网元已删除",
passwordRequired: "请填写密码",
passwordOptional: "留空则不修改",
passwordHint: "直连网元需填写;经堡垒机托管时可留空,后续可用「批量添加代理」统一配置跳板。",
},
hop: {
sectionTitle: "跳板 / 代理",

View file

@ -699,9 +699,23 @@ pre {
font-size: 13px;
font-weight: 400;
line-height: 1.45;
white-space: pre-line;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.help-hint--align-end .help-hint__popover {
left: auto;
right: 0;
}
.panel__toolbar-end {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mini-chart__bar-wrap {
height: 10px;
border-radius: 999px;

View file

@ -12,6 +12,7 @@ import {
managedNeImportTemplateUrl,
updateManagedNe,
} from "../services/api";
import { HelpHint } from "../components/HelpHint";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
@ -350,74 +351,76 @@ export function NePage() {
<section className="panel">
<div className="panel__toolbar">
<h2>{t("managedNe.title")}</h2>
<div className="panel__actions">
<button type="button" onClick={openCreate} disabled={!credsOk}>
{t("managedNe.add")}
</button>
<button
type="button"
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
>
{t("managedNe.downloadTemplate")}
</button>
<button
type="button"
onClick={() => importRef.current?.click()}
disabled={!credsOk || importMutation.isPending}
>
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
</button>
<span className="form-field-hint">{t("managedNe.importHint")}</span>
<input
ref={importRef}
type="file"
accept=".csv,.xlsx,.xls"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) importMutation.mutate(file);
}}
/>
<button
type="button"
disabled={selected.length === 0 || connectMutation.isPending}
onClick={() => connectMutation.mutate(selected)}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
</button>
<button
type="button"
disabled={!credsOk || selected.length === 0 || batchHopMutation.isPending}
onClick={() => {
if (selected.length === 0) {
showError(t("managedNe.hop.selectRequired"));
return;
}
setBatchHop(emptyHopProxyFields());
setBatchHopOpen(true);
}}
>
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
</button>
<button
type="button"
className="btn btn--danger"
disabled={selected.length === 0 || batchDeleteMutation.isPending}
onClick={() => {
if (selected.length === 0) {
showError(t("managedNe.batchDeleteSelectRequired"));
return;
}
if (!window.confirm(t("managedNe.batchDeleteConfirm", { n: selected.length }))) return;
batchDeleteMutation.mutate(selected);
}}
>
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
</button>
<button type="button" onClick={() => invalidateList()}>
{t("common.refresh")}
</button>
<div className="panel__toolbar-end">
<div className="panel__actions">
<button type="button" onClick={openCreate} disabled={!credsOk}>
{t("managedNe.add")}
</button>
<button
type="button"
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
>
{t("managedNe.downloadTemplate")}
</button>
<button
type="button"
onClick={() => importRef.current?.click()}
disabled={!credsOk || importMutation.isPending}
>
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
</button>
<input
ref={importRef}
type="file"
accept=".csv,.xlsx,.xls"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) importMutation.mutate(file);
}}
/>
<button
type="button"
disabled={selected.length === 0 || connectMutation.isPending}
onClick={() => connectMutation.mutate(selected)}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
</button>
<button
type="button"
disabled={!credsOk || selected.length === 0 || batchHopMutation.isPending}
onClick={() => {
if (selected.length === 0) {
showError(t("managedNe.hop.selectRequired"));
return;
}
setBatchHop(emptyHopProxyFields());
setBatchHopOpen(true);
}}
>
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
</button>
<button
type="button"
className="btn btn--danger"
disabled={selected.length === 0 || batchDeleteMutation.isPending}
onClick={() => {
if (selected.length === 0) {
showError(t("managedNe.batchDeleteSelectRequired"));
return;
}
if (!window.confirm(t("managedNe.batchDeleteConfirm", { n: selected.length }))) return;
batchDeleteMutation.mutate(selected);
}}
>
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
</button>
<button type="button" onClick={() => invalidateList()}>
{t("common.refresh")}
</button>
</div>
<HelpHint text={t("managedNe.help")} ariaLabel={t("common.help")} align="end" />
</div>
</div>
@ -643,7 +646,6 @@ export function NePage() {
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<span className="form-field-hint">{t("managedNe.form.passwordHint")}</span>
</label>
<label>
<FormLabel>{t("managedNe.col.tags")}</FormLabel>