mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 18:30:47 +08:00
Tighten NetX help copy and structure HelpHint typography.
Remove permanent list-page hint banners, keep concepts in short HelpHint popovers with scannable sections and lists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
046029aa16
commit
1463652dbb
13 changed files with 336 additions and 81 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -9,20 +9,160 @@ type Props = {
|
|||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const POPOVER_MAX_WIDTH = 360;
|
||||
const POPOVER_MAX_WIDTH = 420;
|
||||
|
||||
type HelpBlock =
|
||||
| { kind: "title"; text: string }
|
||||
| { kind: "list"; items: string[] }
|
||||
| { kind: "para"; text: string };
|
||||
|
||||
const SECTION_RE = /^[【\[](.+?)[】\]]\s*$/;
|
||||
const BULLET_RE = /^[·•\-\*]\s+(.+)$/;
|
||||
/** Highlight field names / env keys / templates — skip short ALLCAPS acronyms (UME, DSH). */
|
||||
const CODEISH_RE =
|
||||
/(`[^`]+`|\{[A-Za-z0-9_./:@-]+\}|[A-Z][A-Z0-9_]{5,}(?::[a-z]+)?|[a-z]+(?:_[a-z0-9]+)+|[a-z]+[A-Z][A-Za-z0-9]*)/g;
|
||||
|
||||
function parseHelpBlocks(raw: string): HelpBlock[] {
|
||||
const lines = String(raw || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd());
|
||||
const blocks: HelpBlock[] = [];
|
||||
let listBuf: string[] = [];
|
||||
let paraBuf: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (!listBuf.length) return;
|
||||
blocks.push({ kind: "list", items: listBuf });
|
||||
listBuf = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (!paraBuf.length) return;
|
||||
blocks.push({ kind: "para", text: paraBuf.join(" ").trim() });
|
||||
paraBuf = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
const section = trimmed.match(SECTION_RE);
|
||||
if (section) {
|
||||
flushList();
|
||||
flushPara();
|
||||
blocks.push({ kind: "title", text: section[1].trim() });
|
||||
continue;
|
||||
}
|
||||
const bullet = trimmed.match(BULLET_RE);
|
||||
if (bullet) {
|
||||
flushPara();
|
||||
listBuf.push(bullet[1].trim());
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
paraBuf.push(trimmed);
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const re = new RegExp(CODEISH_RE.source, "g");
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const raw = m[0];
|
||||
const body = raw.startsWith("`") && raw.endsWith("`") ? raw.slice(1, -1) : raw;
|
||||
nodes.push(
|
||||
<code key={`${m.index}-${body}`} className="help-hint__code">
|
||||
{body}
|
||||
</code>,
|
||||
);
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Long single paragraphs → clause list so plain help is still scannable. */
|
||||
function expandPlainPara(text: string): HelpBlock[] {
|
||||
const parts = text
|
||||
.split(/(?<=[。;;])\s*/)
|
||||
.map((s) => s.replace(/[。;;]\s*$/, "").trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length >= 2 && text.length >= 48) {
|
||||
return [{ kind: "list", items: parts }];
|
||||
}
|
||||
return [{ kind: "para", text }];
|
||||
}
|
||||
|
||||
function HelpBody({ text, nowrap }: { text: string; nowrap: boolean }) {
|
||||
const blocks = useMemo(() => {
|
||||
const parsed = parseHelpBlocks(text);
|
||||
if (nowrap) return parsed;
|
||||
if (parsed.length === 1 && parsed[0].kind === "para") {
|
||||
return expandPlainPara(parsed[0].text);
|
||||
}
|
||||
return parsed.flatMap((b) => (b.kind === "para" && b.text.length >= 96 ? expandPlainPara(b.text) : [b]));
|
||||
}, [text, nowrap]);
|
||||
|
||||
if (nowrap) {
|
||||
return <div className="help-hint__plain">{renderInline(text)}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="help-hint__body">
|
||||
{blocks.map((b, i) => {
|
||||
if (b.kind === "title") {
|
||||
return (
|
||||
<div key={`t-${i}`} className="help-hint__section-title">
|
||||
{b.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (b.kind === "list") {
|
||||
return (
|
||||
<ul key={`l-${i}`} className="help-hint__list">
|
||||
{b.items.map((item, j) => (
|
||||
<li key={j}>{renderInline(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p key={`p-${i}`} className="help-hint__para">
|
||||
{renderInline(b.text)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||
const rootRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const structured = useMemo(() => {
|
||||
const parsed = parseHelpBlocks(text);
|
||||
if (parsed.some((b) => b.kind === "title" || b.kind === "list")) return true;
|
||||
// Long plain paragraphs are auto-split into a list in HelpBody.
|
||||
return parsed.length === 1 && parsed[0].kind === "para" && parsed[0].text.length >= 48;
|
||||
}, [text]);
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const popover = popoverRef.current;
|
||||
const maxWidth = Math.min(POPOVER_MAX_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2);
|
||||
const preferred = structured && !nowrap ? POPOVER_MAX_WIDTH : 360;
|
||||
const maxWidth = Math.min(preferred, window.innerWidth - VIEWPORT_MARGIN * 2);
|
||||
const popWidth = popover?.offsetWidth || maxWidth;
|
||||
const popHeight = popover?.offsetHeight || 0;
|
||||
|
||||
|
|
@ -40,6 +180,7 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
left,
|
||||
right: "auto",
|
||||
maxWidth,
|
||||
maxHeight: Math.min(window.innerHeight - VIEWPORT_MARGIN * 2, 480),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -55,7 +196,7 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
window.removeEventListener("resize", onReflow);
|
||||
window.removeEventListener("scroll", onReflow, true);
|
||||
};
|
||||
}, [open, align, text, nowrap]);
|
||||
}, [open, align, text, nowrap, structured]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -98,11 +239,11 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
ref={popoverRef}
|
||||
className={`help-hint__popover help-hint__popover--portal${
|
||||
nowrap ? " help-hint__popover--nowrap" : ""
|
||||
}`}
|
||||
}${structured && !nowrap ? " help-hint__popover--structured" : ""}`}
|
||||
style={popoverStyle}
|
||||
role="tooltip"
|
||||
>
|
||||
{text}
|
||||
<HelpBody text={text} nowrap={nowrap} />
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -997,6 +997,7 @@ const en = {
|
|||
},
|
||||
bizMonitorTpl: {
|
||||
title: "Monitor templates",
|
||||
help: "Compare templates define align/diff; monitor templates define migrate success on the same sheets.",
|
||||
hint: "Compare templates define HOW to align/diff; monitor templates define WHEN dual-device migrate succeeds — one sheet tab per compare sheet.",
|
||||
create: "New monitor template",
|
||||
edit: "Edit",
|
||||
|
|
@ -1568,6 +1569,8 @@ const en = {
|
|||
loginFailed: "Login failed",
|
||||
logout: "Sign out",
|
||||
sessionsTitle: "Login sessions",
|
||||
sessionsHelp:
|
||||
"Behind a proxy, IP is the real client; use Client to tell browsers apart. Revoked sessions must sign in again.",
|
||||
sessionsHint:
|
||||
"Manage browser/device logins for this account. Behind a reverse proxy, IP is taken from trusted forwarded headers; use the Client column to tell browsers apart on the same public IP. Revoked sessions must sign in again.",
|
||||
sessionsEmpty: "No active sessions.",
|
||||
|
|
@ -1611,7 +1614,7 @@ const en = {
|
|||
colIp: "IP",
|
||||
apiKeysTitle: "API Key management",
|
||||
apiKeysHelp:
|
||||
"The web UI uses login sessions, not API keys — keys are for MCP/scripts and netxops. Defaults on create: alarms:read + ne:read + ne:write + ne:exec + biz-monitor:read; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved. System-wide cap: {{max}} non-revoked keys (revoke or delete to free a slot).",
|
||||
"For MCP/scripts and netxops (web UI uses sessions). Secret shown once; Edit scopes later. Cap {{max}} non-revoked keys.",
|
||||
tokenQuota: "Active {{active}} / {{max}}",
|
||||
tokenLimitReached: "API key limit reached ({{max}}). Revoke or delete unused keys first.",
|
||||
tokenName: "Name",
|
||||
|
|
@ -1889,16 +1892,16 @@ const en = {
|
|||
},
|
||||
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" +
|
||||
"· Recommended flow: import NEs first, select rows, then use Batch add proxy.\n\n" +
|
||||
"· Required: device_type, ip, username, port, protocol, name, vendor (download template first)\n" +
|
||||
"· password may be empty for bastion-managed / later Batch add proxy; required for direct login\n" +
|
||||
"· Flow: import → select rows → Batch add proxy\n\n" +
|
||||
"[Jump / bastion]\n" +
|
||||
"· Bastion SSH username template: {hop_user}@{target_user}@{target_ip} (bastion host is separate IP or FQDN); CLI: ssh user@target@ip@bastion-host.\n" +
|
||||
"· Bastion-managed: set Jump password (Vault); target password optional. Manual mode needs target password.\n" +
|
||||
"· Jump host accepts IP (192.0.2.10) or domain (ssh-bastion.example.com). JumpServer/CBH often use port 2222; some sites use 22.\n\n" +
|
||||
"· Username template: {hop_user}@{target_user}@{target_ip} (bastion host filled separately)\n" +
|
||||
"· Managed mode: Jump password (Vault); target password optional. Manual mode needs target password\n" +
|
||||
"· JumpServer/CBH ports often 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).",
|
||||
"· Storing passwords needs NETX_CREDENTIAL_SECRET_KEY\n" +
|
||||
"· Blank password on edit keeps current; use Details hop context after failed connect (no secrets)",
|
||||
importResult: {
|
||||
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
||||
},
|
||||
|
|
@ -2005,7 +2008,12 @@ const en = {
|
|||
subscription: {
|
||||
title: "UME alarm subscription (WebSocket)",
|
||||
help:
|
||||
"Subscriptions must be created/cancelled manually. After establish (or on restart if a valid subscription exists in DB), the backend auto-connects WSS for live alarms. Pause/resume on runtime task alarms_current_ws_consumer stops/resumes WSS only (does not cancel the UME subscription).",
|
||||
"[Subscription]\n" +
|
||||
"· Create / cancel manually\n" +
|
||||
"· After establish (or restart with a valid DB record), backend auto-connects WSS\n\n" +
|
||||
"[Task control]\n" +
|
||||
"· alarms_current_ws_consumer pause/resume only stops/starts WSS\n" +
|
||||
"· Does not cancel the UME subscription",
|
||||
sub: "subscription",
|
||||
subActive: "active",
|
||||
subInactive: "inactive",
|
||||
|
|
@ -2046,7 +2054,14 @@ const en = {
|
|||
title: "AI alarm monitor",
|
||||
showPanel: "Details",
|
||||
hidePanel: "Close",
|
||||
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.). Optionally restrict to UME inventory ne_type values (multi-select; empty = all devices). Matched alerts are broadcast by NetX (server) to subscribed Netx Ops (DSH) clients.",
|
||||
help:
|
||||
"[Match]\n" +
|
||||
"· Exact notificationId\n" +
|
||||
"· Or description keyword (case-insensitive; includes nativeProbableCause)\n\n" +
|
||||
"[Scope]\n" +
|
||||
"· Optional Inventory ne_type filter; empty = all devices\n\n" +
|
||||
"[Deliver]\n" +
|
||||
"· NetX broadcasts hits to subscribed Netx Ops (DSH) clients",
|
||||
hub: "DSH subscribers",
|
||||
hubConnected: "subscribed",
|
||||
hubEmpty: "no subscribers",
|
||||
|
|
@ -2196,7 +2211,11 @@ const en = {
|
|||
title: "UME CLI connection profile",
|
||||
showPanel: "Details",
|
||||
hidePanel: "Close",
|
||||
hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.",
|
||||
hint:
|
||||
"[Purpose]\n" +
|
||||
"· Unified SSH credentials and hop/bastion for Inventory NEs\n\n" +
|
||||
"[MCP]\n" +
|
||||
"· Exec show via ume_ne_id without per-device managed_ne entries",
|
||||
statusReady: "CLI profile is ready for UME NE command execution.",
|
||||
statusNotReady: "Set target username and hop/bastion settings (if enabled).",
|
||||
profileName: "Profile name",
|
||||
|
|
|
|||
|
|
@ -992,6 +992,7 @@ const zh = {
|
|||
},
|
||||
bizMonitorTpl: {
|
||||
title: "监控模板",
|
||||
help: "对比模板定义怎么对齐/比对;监控模板按同表定义搬迁成功条件。",
|
||||
hint: "对比模板定义「怎么对齐/比对」;监控模板按同样的表(sheet)定义「怎样算搬迁成功/失败」。",
|
||||
create: "新建监控模板",
|
||||
edit: "编辑",
|
||||
|
|
@ -1556,6 +1557,7 @@ const zh = {
|
|||
loginFailed: "登录失败",
|
||||
logout: "退出",
|
||||
sessionsTitle: "登录会话",
|
||||
sessionsHelp: "反代后 IP 取真实客户端;同公网可用「客户端」列区分。踢掉后需重新登录。",
|
||||
sessionsHint:
|
||||
"管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。",
|
||||
sessionsEmpty: "当前没有活跃会话。",
|
||||
|
|
@ -1599,7 +1601,7 @@ const zh = {
|
|||
colIp: "IP",
|
||||
apiKeysTitle: "API Key 管理",
|
||||
apiKeysHelp:
|
||||
"网页登录不用 API Key,Key 给 MCP/脚本与 netxops 用。生成时默认 alarms:read + ne:read + ne:write + ne:exec + biz-monitor:read,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接的项为预留。全站未吊销 Key 上限 {{max}} 把(吊销或删除后可再建)。",
|
||||
"供 MCP/脚本与 netxops 使用(网页登录用会话)。明文仅创建时显示一次;可用「改权限」调整。未吊销上限 {{max}}。",
|
||||
tokenQuota: "未吊销 {{active}} / {{max}}",
|
||||
tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销或删除不用的 Key",
|
||||
tokenName: "名称",
|
||||
|
|
@ -1873,16 +1875,16 @@ const zh = {
|
|||
},
|
||||
help:
|
||||
"【批量导入】\n" +
|
||||
"· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" +
|
||||
"· password 可留空(直连需填;堡垒机托管或后续批量添加代理时可空)。\n" +
|
||||
"· 推荐流程:先导入网元 → 勾选网元 → 点「批量添加代理」统一配置跳板/堡垒机。\n\n" +
|
||||
"· 必填列:device_type、ip、username、port、protocol、name、vendor(可先下载模板)\n" +
|
||||
"· password 可留空:直连需填;堡垒机托管或后续「批量添加代理」时可空\n" +
|
||||
"· 推荐:导入网元 → 勾选 →「批量添加代理」统一配置跳板\n\n" +
|
||||
"【跳板 / 堡垒机】\n" +
|
||||
"· 堡垒机 SSH 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址单独填在跳板地址);命令行等价:ssh 用户@目标@IP@堡垒机。\n" +
|
||||
"· 堡垒机托管时填「跳板密码」(Vault 密码),目标密码可留空;手动模式需填目标密码。\n" +
|
||||
"· JumpServer/CBH 常用跳板端口 2222,部分现场为 22。\n\n" +
|
||||
"· 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址另填跳板地址)\n" +
|
||||
"· 托管模式填跳板密码(Vault);目标密码可空。手动模式需填目标密码\n" +
|
||||
"· JumpServer/CBH 跳板端口多为 2222,部分现场为 22\n\n" +
|
||||
"【连通性 / 编辑】\n" +
|
||||
"· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY。\n" +
|
||||
"· 编辑时密码留空表示不修改;连通性失败请点「详情」查看 hop 上下文(不含密码)。",
|
||||
"· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY\n" +
|
||||
"· 编辑时密码留空表示不改;失败请看「详情」中的 hop 上下文(不含密码)",
|
||||
importResult: {
|
||||
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
||||
},
|
||||
|
|
@ -1989,7 +1991,12 @@ const zh = {
|
|||
subscription: {
|
||||
title: "UME 告警订阅(WebSocket)",
|
||||
help:
|
||||
"订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。后台任务 alarms_current_ws_consumer 的 pause/resume 会停止或恢复 WSS 连接(不会取消 UME 订阅)。",
|
||||
"【订阅】\n" +
|
||||
"· 需手动建立 / 取消\n" +
|
||||
"· 建立后(或重启且库中仍有效)后台自动连 WSS 收实时告警\n\n" +
|
||||
"【任务控制】\n" +
|
||||
"· alarms_current_ws_consumer 的 pause/resume 只停/启 WSS\n" +
|
||||
"· 不会取消 UME 侧订阅",
|
||||
sub: "订阅",
|
||||
subActive: "已建立",
|
||||
subInactive: "未建立",
|
||||
|
|
@ -2030,7 +2037,14 @@ const zh = {
|
|||
title: "AI 告警监控",
|
||||
showPanel: "详情",
|
||||
hidePanel: "关闭",
|
||||
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段);可选限定 UME Inventory 中的网元类型 (ne_type),不选则匹配全部设备。命中后由 NetX 作为服务器广播给已订阅的 Netx Ops(DSH)客户端。",
|
||||
help:
|
||||
"【匹配】\n" +
|
||||
"· notificationId 精确匹配\n" +
|
||||
"· 或告警描述关键字模糊匹配(不区分大小写,含 nativeProbableCause 等)\n\n" +
|
||||
"【范围】\n" +
|
||||
"· 可选限定 Inventory 的 ne_type;不选则匹配全部设备\n\n" +
|
||||
"【投递】\n" +
|
||||
"· 命中后由 NetX 广播给已订阅的 Netx Ops(DSH)客户端",
|
||||
hub: "DSH 订阅",
|
||||
hubConnected: "有订阅",
|
||||
hubEmpty: "无订阅",
|
||||
|
|
@ -2180,7 +2194,11 @@ const zh = {
|
|||
title: "UME CLI 连接配置",
|
||||
showPanel: "详情",
|
||||
hidePanel: "关闭",
|
||||
hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。",
|
||||
hint:
|
||||
"【用途】\n" +
|
||||
"· 为 Inventory 网元配置统一 SSH 凭据与跳板/堡垒机\n\n" +
|
||||
"【MCP】\n" +
|
||||
"· 可用 ume_ne_id 直接执行 show,无需逐台录入 managed_ne",
|
||||
statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。",
|
||||
statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",
|
||||
profileName: "配置名称",
|
||||
|
|
|
|||
|
|
@ -1918,15 +1918,16 @@ pre {
|
|||
z-index: 40;
|
||||
width: min(360px, 80vw);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--nm-ops-line, #2b3748);
|
||||
border-radius: 6px;
|
||||
background: #0c1521;
|
||||
color: #d7e3f2;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.help-hint--align-end .help-hint__popover {
|
||||
|
|
@ -1939,12 +1940,80 @@ pre {
|
|||
width: auto;
|
||||
}
|
||||
|
||||
.help-hint__popover--structured {
|
||||
width: min(420px, 86vw);
|
||||
white-space: normal;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.help-hint__popover--nowrap {
|
||||
width: max-content;
|
||||
max-width: min(90vw, 720px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.help-hint__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.help-hint__plain {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.help-hint__section-title {
|
||||
margin: 2px 0 0;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid var(--nm-ops-line, #2b3748);
|
||||
color: var(--nm-ops-accent, #6ba9ec);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.help-hint__section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.help-hint__list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1.05rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--nm-ops-muted, #9aa9bc);
|
||||
}
|
||||
|
||||
.help-hint__list > li {
|
||||
padding-left: 2px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.help-hint__list > li::marker {
|
||||
color: #526f94;
|
||||
}
|
||||
|
||||
.help-hint__para {
|
||||
margin: 0;
|
||||
color: var(--nm-ops-muted, #9aa9bc);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-hint__code {
|
||||
display: inline;
|
||||
padding: 0 4px;
|
||||
border: 1px solid rgba(43, 55, 72, 0.9);
|
||||
border-radius: 3px;
|
||||
background: #111c2a;
|
||||
color: #c4ddfa;
|
||||
font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel__toolbar-end {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
|
@ -2226,9 +2295,9 @@ pre {
|
|||
}
|
||||
|
||||
.system-page .panel__hint {
|
||||
margin: -6px 0 12px;
|
||||
color: var(--nm-muted);
|
||||
font-size: 13px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--nm-ops-muted, var(--nm-muted));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ume-page .panel > h3,
|
||||
|
|
@ -3246,15 +3315,20 @@ pre {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Status / empty-state copy only — conceptual docs go in HelpHint on the title. */
|
||||
.panel__hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--nm-ops-muted, #9aa9bc);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.panel__hint--live {
|
||||
color: var(--nm-accent, #3b82f6);
|
||||
color: var(--nm-ops-accent, #6ba9ec);
|
||||
border: 1px solid var(--nm-ops-line, #2b3748);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(107, 169, 236, 0.06);
|
||||
}
|
||||
|
||||
.collect-commands {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Button, Checkbox, Input, Modal } from "@heroui/react";
|
|||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { AppModalShell } from "../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../components/ui/FieldSelect";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -226,14 +227,9 @@ export function ApiTokensPage() {
|
|||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2 className="token-page__title">
|
||||
<h2 className="token-page__title card-title-with-hint">
|
||||
{t("auth.apiKeysTitle")}
|
||||
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp", { max: tokenMax || 20 })}>
|
||||
?
|
||||
<span className="help-q__tip" role="tooltip">
|
||||
{t("auth.apiKeysHelp", { max: tokenMax || 20 })}
|
||||
</span>
|
||||
</span>
|
||||
<HelpHint text={t("auth.apiKeysHelp", { max: tokenMax || 20 })} ariaLabel={t("common.help")} />
|
||||
</h2>
|
||||
{tokenMax > 0 ? (
|
||||
<span className="muted" style={{ fontSize: 13 }}>
|
||||
|
|
|
|||
|
|
@ -639,7 +639,6 @@ export function CollectPage() {
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
{policyScopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
|
|
@ -817,7 +816,6 @@ export function CollectPage() {
|
|||
<Modal.CloseTrigger isDisabled={createMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="muted">{t("collect.create.hint")}</p>
|
||||
<div className="collect-create-modal">
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
|
|
@ -839,7 +837,6 @@ export function CollectPage() {
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
<div className="collect-selected-block">
|
||||
<div className="collect-selected-block__head">
|
||||
|
|
@ -854,7 +851,7 @@ export function CollectPage() {
|
|||
) : null}
|
||||
</div>
|
||||
{selectedList.length === 0 ? (
|
||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
<p className="muted">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => {
|
||||
|
|
@ -884,7 +881,6 @@ export function CollectPage() {
|
|||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h3>{t("collect.create.pickTitle")}</h3>
|
||||
<p className="panel__hint">{t("collect.create.pickHint")}</p>
|
||||
</div>
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiDelete, apiGet, apiPost } from "../services/api";
|
||||
|
|
@ -81,9 +82,11 @@ export function SessionsPage() {
|
|||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("auth.sessionsTitle")}</h2>
|
||||
<h2 className="card-title-with-hint">
|
||||
{t("auth.sessionsTitle")}
|
||||
<HelpHint text={t("auth.sessionsHelp")} ariaLabel={t("common.help")} />
|
||||
</h2>
|
||||
</div>
|
||||
<p className="panel__hint">{t("auth.sessionsHint")}</p>
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -873,7 +873,6 @@ export function UmePage() {
|
|||
{t("ume.cli.showPanel")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="ume-entry__summary muted">{t("ume.cli.hint")}</p>
|
||||
</article>
|
||||
|
||||
<article className="panel ume-entry nm-page-panel">
|
||||
|
|
@ -966,7 +965,6 @@ export function UmePage() {
|
|||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="muted">{t("ume.cli.hint")}</p>
|
||||
<UmeCliConnectPanel embedded enabled />
|
||||
</Modal.Body>
|
||||
</AppModalShell>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ export function UsersPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("auth.usersTitle")}</h2>
|
||||
</div>
|
||||
<p className="panel__hint">{t("auth.usersHint")}</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<form className="filter-inline" onSubmit={onCreate}>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ export function TaskOverviewPage() {
|
|||
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="panel__hint">{t("audit.tasks.hint")}</p>
|
||||
|
||||
<div className="pt-list-kpis">
|
||||
<div className="pt-list-kpi pt-list-kpi--live">
|
||||
|
|
|
|||
|
|
@ -2498,9 +2498,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
|
||||
{activeTab === "templates" ? (
|
||||
<div className="pt-list-table-wrap">
|
||||
<p className="muted" style={{ margin: "0 0 8px" }}>
|
||||
{t("bizCompare.templateImportHint")}
|
||||
</p>
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
|
|
@ -383,6 +383,7 @@ function fmtTime(v?: string | null) {
|
|||
|
||||
export function BizMigrationPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { showOk, showError } = useToast();
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
|
@ -1694,17 +1695,25 @@ export function BizMigrationPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("bizMigration.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => navigate("/network/cutover/monitor-templates")}
|
||||
>
|
||||
{t("network.nav.monitorTemplates")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => navigate("/network/cutover/compare-templates")}
|
||||
>
|
||||
{t("network.nav.compareTemplates")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onPress={openCreate}>
|
||||
{t("bizMigration.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="panel__hint muted">
|
||||
{t("bizMigration.hintShort")}{" "}
|
||||
<Link to="/network/cutover/monitor-templates">{t("network.nav.monitorTemplates")}</Link>
|
||||
{" · "}
|
||||
<Link to="/network/cutover/compare-templates">{t("network.nav.compareTemplates")}</Link>
|
||||
</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="pt-list-kpis">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
|
|
@ -806,6 +807,7 @@ function DualPatternList({
|
|||
|
||||
export function BizMonitorTemplatesPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { showOk, showError } = useToast();
|
||||
|
||||
const [items, setItems] = useState<MonitorTpl[]>([]);
|
||||
|
|
@ -1105,7 +1107,10 @@ export function BizMonitorTemplatesPage() {
|
|||
return (
|
||||
<section className="panel nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("bizMonitorTpl.title")}</h2>
|
||||
<h2 className="card-title-with-hint">
|
||||
{t("bizMonitorTpl.title")}
|
||||
<HelpHint text={t("bizMonitorTpl.help")} ariaLabel={t("common.help")} />
|
||||
</h2>
|
||||
<div className="btn-row">
|
||||
<input
|
||||
ref={tplImportRef}
|
||||
|
|
@ -1117,6 +1122,13 @@ export function BizMonitorTemplatesPage() {
|
|||
if (f) void importTemplateFile(f);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => navigate("/network/cutover/compare-templates")}
|
||||
>
|
||||
{t("bizMonitorTpl.openCompare")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
|
@ -1130,12 +1142,6 @@ export function BizMonitorTemplatesPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="panel__hint muted">{t("bizMonitorTpl.hint")}</p>
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: -4 }}>
|
||||
<Link to="/network/cutover/compare-templates">{t("bizMonitorTpl.openCompare")}</Link>
|
||||
{" · "}
|
||||
{t("bizMonitorTpl.templateImportHint")}
|
||||
</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="pt-list-kpis">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue