mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 22:10:48 +08:00
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:
parent
7d74e7b3f4
commit
81c028949b
5 changed files with 114 additions and 78 deletions
|
|
@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from "react";
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
ariaLabel: 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 [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLSpanElement>(null);
|
const rootRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ export function HelpHint({ text, ariaLabel }: Props) {
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="help-hint" ref={rootRef}>
|
<span className={`help-hint${align === "end" ? " help-hint--align-end" : ""}`} ref={rootRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="help-hint__trigger"
|
className="help-hint__trigger"
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,19 @@ const en = {
|
||||||
connectDetailTitle: "Connectivity test log",
|
connectDetailTitle: "Connectivity test log",
|
||||||
connectDetailEmpty:
|
connectDetailEmpty:
|
||||||
"No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).",
|
"No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).",
|
||||||
importHint:
|
help:
|
||||||
"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.",
|
"[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: {
|
importResult: {
|
||||||
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
||||||
},
|
},
|
||||||
|
|
@ -187,8 +198,6 @@ const en = {
|
||||||
deleted: "Network element deleted",
|
deleted: "Network element deleted",
|
||||||
passwordRequired: "Password is required",
|
passwordRequired: "Password is required",
|
||||||
passwordOptional: "leave blank to keep unchanged",
|
passwordOptional: "leave blank to keep unchanged",
|
||||||
passwordHint:
|
|
||||||
"Required for direct login; leave empty when bastion manages target credentials. Use batch proxy later.",
|
|
||||||
},
|
},
|
||||||
hop: {
|
hop: {
|
||||||
sectionTitle: "Jump host / proxy",
|
sectionTitle: "Jump host / proxy",
|
||||||
|
|
|
||||||
|
|
@ -167,8 +167,19 @@ const zh = {
|
||||||
connectDetail: "详情",
|
connectDetail: "详情",
|
||||||
connectDetailTitle: "连通性测试日志",
|
connectDetailTitle: "连通性测试日志",
|
||||||
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。",
|
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。",
|
||||||
importHint:
|
help:
|
||||||
"导入模板含可选跳板列(hop_enabled 等);也可先导入网元再批量添加代理。密码可留空(堡垒机托管场景)。",
|
"【批量导入】\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: {
|
importResult: {
|
||||||
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
||||||
},
|
},
|
||||||
|
|
@ -185,7 +196,6 @@ const zh = {
|
||||||
deleted: "网元已删除",
|
deleted: "网元已删除",
|
||||||
passwordRequired: "请填写密码",
|
passwordRequired: "请填写密码",
|
||||||
passwordOptional: "留空则不修改",
|
passwordOptional: "留空则不修改",
|
||||||
passwordHint: "直连网元需填写;经堡垒机托管时可留空,后续可用「批量添加代理」统一配置跳板。",
|
|
||||||
},
|
},
|
||||||
hop: {
|
hop: {
|
||||||
sectionTitle: "跳板 / 代理",
|
sectionTitle: "跳板 / 代理",
|
||||||
|
|
|
||||||
|
|
@ -699,9 +699,23 @@ pre {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
white-space: pre-line;
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
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 {
|
.mini-chart__bar-wrap {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
managedNeImportTemplateUrl,
|
managedNeImportTemplateUrl,
|
||||||
updateManagedNe,
|
updateManagedNe,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
|
import { HelpHint } from "../components/HelpHint";
|
||||||
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
|
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields";
|
||||||
import { queryKeys } from "../constants/queryKeys";
|
import { queryKeys } from "../constants/queryKeys";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
|
@ -350,74 +351,76 @@ export function NePage() {
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel__toolbar">
|
<div className="panel__toolbar">
|
||||||
<h2>{t("managedNe.title")}</h2>
|
<h2>{t("managedNe.title")}</h2>
|
||||||
<div className="panel__actions">
|
<div className="panel__toolbar-end">
|
||||||
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
<div className="panel__actions">
|
||||||
{t("managedNe.add")}
|
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
||||||
</button>
|
{t("managedNe.add")}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
|
type="button"
|
||||||
>
|
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
|
||||||
{t("managedNe.downloadTemplate")}
|
>
|
||||||
</button>
|
{t("managedNe.downloadTemplate")}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => importRef.current?.click()}
|
type="button"
|
||||||
disabled={!credsOk || importMutation.isPending}
|
onClick={() => importRef.current?.click()}
|
||||||
>
|
disabled={!credsOk || importMutation.isPending}
|
||||||
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
|
>
|
||||||
</button>
|
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
|
||||||
<span className="form-field-hint">{t("managedNe.importHint")}</span>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={importRef}
|
ref={importRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.xlsx,.xls"
|
accept=".csv,.xlsx,.xls"
|
||||||
hidden
|
hidden
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
if (file) importMutation.mutate(file);
|
if (file) importMutation.mutate(file);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={selected.length === 0 || connectMutation.isPending}
|
disabled={selected.length === 0 || connectMutation.isPending}
|
||||||
onClick={() => connectMutation.mutate(selected)}
|
onClick={() => connectMutation.mutate(selected)}
|
||||||
>
|
>
|
||||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
|
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!credsOk || selected.length === 0 || batchHopMutation.isPending}
|
disabled={!credsOk || selected.length === 0 || batchHopMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selected.length === 0) {
|
if (selected.length === 0) {
|
||||||
showError(t("managedNe.hop.selectRequired"));
|
showError(t("managedNe.hop.selectRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBatchHop(emptyHopProxyFields());
|
setBatchHop(emptyHopProxyFields());
|
||||||
setBatchHopOpen(true);
|
setBatchHopOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
|
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--danger"
|
className="btn btn--danger"
|
||||||
disabled={selected.length === 0 || batchDeleteMutation.isPending}
|
disabled={selected.length === 0 || batchDeleteMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selected.length === 0) {
|
if (selected.length === 0) {
|
||||||
showError(t("managedNe.batchDeleteSelectRequired"));
|
showError(t("managedNe.batchDeleteSelectRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(t("managedNe.batchDeleteConfirm", { n: selected.length }))) return;
|
if (!window.confirm(t("managedNe.batchDeleteConfirm", { n: selected.length }))) return;
|
||||||
batchDeleteMutation.mutate(selected);
|
batchDeleteMutation.mutate(selected);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
|
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => invalidateList()}>
|
<button type="button" onClick={() => invalidateList()}>
|
||||||
{t("common.refresh")}
|
{t("common.refresh")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<HelpHint text={t("managedNe.help")} ariaLabel={t("common.help")} align="end" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -643,7 +646,6 @@ export function NePage() {
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<span className="form-field-hint">{t("managedNe.form.passwordHint")}</span>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
|
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue