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:
oliver 2026-10-11 16:44:10 +08:00
parent 046029aa16
commit 1463652dbb
13 changed files with 336 additions and 81 deletions

View file

@ -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"; import { createPortal } from "react-dom";
type Props = { type Props = {
@ -9,20 +9,160 @@ type Props = {
}; };
const VIEWPORT_MARGIN = 8; 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) { export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({}); const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
const rootRef = useRef<HTMLSpanElement>(null); const rootRef = useRef<HTMLSpanElement>(null);
const popoverRef = useRef<HTMLDivElement>(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 updatePopoverPosition = () => {
const el = rootRef.current; const el = rootRef.current;
if (!el) return; if (!el) return;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const popover = popoverRef.current; 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 popWidth = popover?.offsetWidth || maxWidth;
const popHeight = popover?.offsetHeight || 0; const popHeight = popover?.offsetHeight || 0;
@ -40,6 +180,7 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
left, left,
right: "auto", right: "auto",
maxWidth, 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("resize", onReflow);
window.removeEventListener("scroll", onReflow, true); window.removeEventListener("scroll", onReflow, true);
}; };
}, [open, align, text, nowrap]); }, [open, align, text, nowrap, structured]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -98,11 +239,11 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
ref={popoverRef} ref={popoverRef}
className={`help-hint__popover help-hint__popover--portal${ className={`help-hint__popover help-hint__popover--portal${
nowrap ? " help-hint__popover--nowrap" : "" nowrap ? " help-hint__popover--nowrap" : ""
}`} }${structured && !nowrap ? " help-hint__popover--structured" : ""}`}
style={popoverStyle} style={popoverStyle}
role="tooltip" role="tooltip"
> >
{text} <HelpBody text={text} nowrap={nowrap} />
</div>, </div>,
document.body, document.body,
) )

View file

@ -997,6 +997,7 @@ const en = {
}, },
bizMonitorTpl: { bizMonitorTpl: {
title: "Monitor templates", 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.", 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", create: "New monitor template",
edit: "Edit", edit: "Edit",
@ -1568,6 +1569,8 @@ const en = {
loginFailed: "Login failed", loginFailed: "Login failed",
logout: "Sign out", logout: "Sign out",
sessionsTitle: "Login sessions", 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: 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.", "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.", sessionsEmpty: "No active sessions.",
@ -1611,7 +1614,7 @@ const en = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key management", apiKeysTitle: "API Key management",
apiKeysHelp: 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}}", tokenQuota: "Active {{active}} / {{max}}",
tokenLimitReached: "API key limit reached ({{max}}). Revoke or delete unused keys first.", tokenLimitReached: "API key limit reached ({{max}}). Revoke or delete unused keys first.",
tokenName: "Name", tokenName: "Name",
@ -1889,16 +1892,16 @@ const en = {
}, },
help: help:
"[Bulk import]\n" + "[Bulk import]\n" +
"· Required columns: device_type, ip, username, port, protocol, name, vendor. Download the template first.\n" + "· Required: device_type, ip, username, port, protocol, name, vendor (download template first)\n" +
"· password may be empty (required for direct login; optional for bastion-managed or batch proxy later).\n" + "· password may be empty for bastion-managed / later Batch add proxy; required for direct login\n" +
"· Recommended flow: import NEs first, select rows, then use Batch add proxy.\n\n" + "· Flow: import → select rows → Batch add proxy\n\n" +
"[Jump / bastion]\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" + "· Username template: {hop_user}@{target_user}@{target_ip} (bastion host filled separately)\n" +
"· Bastion-managed: set Jump password (Vault); target password optional. Manual mode needs target password.\n" + "· Managed mode: 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" + "· JumpServer/CBH ports often 2222; some sites use 22\n\n" +
"[Connectivity / edit]\n" + "[Connectivity / edit]\n" +
"· NETX_CREDENTIAL_SECRET_KEY required to store passwords.\n" + "· Storing passwords needs NETX_CREDENTIAL_SECRET_KEY\n" +
"· Leave password blank on edit to keep unchanged; open Details after failed connect-test for hop context (no secrets).", "· Blank password on edit keeps current; use Details hop context after failed connect (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)",
}, },
@ -2005,7 +2008,12 @@ const en = {
subscription: { subscription: {
title: "UME alarm subscription (WebSocket)", title: "UME alarm subscription (WebSocket)",
help: 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", sub: "subscription",
subActive: "active", subActive: "active",
subInactive: "inactive", subInactive: "inactive",
@ -2046,7 +2054,14 @@ const en = {
title: "AI alarm monitor", title: "AI alarm monitor",
showPanel: "Details", showPanel: "Details",
hidePanel: "Close", 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", hub: "DSH subscribers",
hubConnected: "subscribed", hubConnected: "subscribed",
hubEmpty: "no subscribers", hubEmpty: "no subscribers",
@ -2196,7 +2211,11 @@ const en = {
title: "UME CLI connection profile", title: "UME CLI connection profile",
showPanel: "Details", showPanel: "Details",
hidePanel: "Close", 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.", statusReady: "CLI profile is ready for UME NE command execution.",
statusNotReady: "Set target username and hop/bastion settings (if enabled).", statusNotReady: "Set target username and hop/bastion settings (if enabled).",
profileName: "Profile name", profileName: "Profile name",

View file

@ -992,6 +992,7 @@ const zh = {
}, },
bizMonitorTpl: { bizMonitorTpl: {
title: "监控模板", title: "监控模板",
help: "对比模板定义怎么对齐/比对;监控模板按同表定义搬迁成功条件。",
hint: "对比模板定义「怎么对齐/比对」;监控模板按同样的表(sheet)定义「怎样算搬迁成功/失败」。", hint: "对比模板定义「怎么对齐/比对」;监控模板按同样的表(sheet)定义「怎样算搬迁成功/失败」。",
create: "新建监控模板", create: "新建监控模板",
edit: "编辑", edit: "编辑",
@ -1556,6 +1557,7 @@ const zh = {
loginFailed: "登录失败", loginFailed: "登录失败",
logout: "退出", logout: "退出",
sessionsTitle: "登录会话", sessionsTitle: "登录会话",
sessionsHelp: "反代后 IP 取真实客户端;同公网可用「客户端」列区分。踢掉后需重新登录。",
sessionsHint: sessionsHint:
"管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。", "管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。",
sessionsEmpty: "当前没有活跃会话。", sessionsEmpty: "当前没有活跃会话。",
@ -1599,7 +1601,7 @@ const zh = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key 管理", apiKeysTitle: "API Key 管理",
apiKeysHelp: 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}}", tokenQuota: "未吊销 {{active}} / {{max}}",
tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销或删除不用的 Key", tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销或删除不用的 Key",
tokenName: "名称", tokenName: "名称",
@ -1873,16 +1875,16 @@ const zh = {
}, },
help: help:
"【批量导入】\n" + "【批量导入】\n" +
"· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" + "· 必填列:device_type、ip、username、port、protocol、name、vendor(可先下载模板)\n" +
"· password 可留空(直连需填;堡垒机托管或后续批量添加代理时可空)。\n" + "· password 可留空:直连需填;堡垒机托管或后续「批量添加代理」时可空\n" +
"· 推荐流程:先导入网元 → 勾选网元 → 点「批量添加代理」统一配置跳板/堡垒机。\n\n" + "· 推荐:导入网元 → 勾选 →「批量添加代理」统一配置跳板\n\n" +
"【跳板 / 堡垒机】\n" + "【跳板 / 堡垒机】\n" +
"· 堡垒机 SSH 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址单独填在跳板地址);命令行等价:ssh 用户@目标@IP@堡垒机。\n" + "· 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址另填跳板地址)\n" +
"· 堡垒机托管时填「跳板密码」(Vault 密码),目标密码可留空;手动模式需填目标密码。\n" + "· 托管模式填跳板密码(Vault);目标密码可空。手动模式需填目标密码\n" +
"· JumpServer/CBH 常用跳板端口 2222,部分现场为 22。\n\n" + "· JumpServer/CBH 跳板端口多为 2222,部分现场为 22\n\n" +
"【连通性 / 编辑】\n" + "【连通性 / 编辑】\n" +
"· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY。\n" + "· 保存密码需配置 NETX_CREDENTIAL_SECRET_KEY\n" +
"· 编辑时密码留空表示不修改;连通性失败请点「详情」查看 hop 上下文(不含密码)。", "· 编辑时密码留空表示不改;失败请看「详情」中的 hop 上下文(不含密码)",
importResult: { importResult: {
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行", done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
}, },
@ -1989,7 +1991,12 @@ const zh = {
subscription: { subscription: {
title: "UME 告警订阅(WebSocket)", title: "UME 告警订阅(WebSocket)",
help: 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: "订阅", sub: "订阅",
subActive: "已建立", subActive: "已建立",
subInactive: "未建立", subInactive: "未建立",
@ -2030,7 +2037,14 @@ const zh = {
title: "AI 告警监控", title: "AI 告警监控",
showPanel: "详情", showPanel: "详情",
hidePanel: "关闭", 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 订阅", hub: "DSH 订阅",
hubConnected: "有订阅", hubConnected: "有订阅",
hubEmpty: "无订阅", hubEmpty: "无订阅",
@ -2180,7 +2194,11 @@ const zh = {
title: "UME CLI 连接配置", title: "UME CLI 连接配置",
showPanel: "详情", showPanel: "详情",
hidePanel: "关闭", 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 网元执行命令。", statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。",
statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。", statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",
profileName: "配置名称", profileName: "配置名称",

View file

@ -1918,15 +1918,16 @@ pre {
z-index: 40; z-index: 40;
width: min(360px, 80vw); width: min(360px, 80vw);
padding: 10px 12px; padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid var(--nm-ops-line, #2b3748);
border-radius: 8px; border-radius: 6px;
background: #0f172a; background: #0c1521;
color: #e2e8f0; color: #d7e3f2;
font-size: 13px; font-size: 12px;
font-weight: 400; font-weight: 400;
line-height: 1.45; line-height: 1.5;
white-space: pre-line; 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 { .help-hint--align-end .help-hint__popover {
@ -1939,12 +1940,80 @@ pre {
width: auto; width: auto;
} }
.help-hint__popover--structured {
width: min(420px, 86vw);
white-space: normal;
padding: 12px 14px;
}
.help-hint__popover--nowrap { .help-hint__popover--nowrap {
width: max-content; width: max-content;
max-width: min(90vw, 720px); max-width: min(90vw, 720px);
white-space: nowrap; 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 { .panel__toolbar-end {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@ -2226,9 +2295,9 @@ pre {
} }
.system-page .panel__hint { .system-page .panel__hint {
margin: -6px 0 12px; margin: 0 0 10px;
color: var(--nm-muted); color: var(--nm-ops-muted, var(--nm-muted));
font-size: 13px; font-size: 12px;
} }
.ume-page .panel > h3, .ume-page .panel > h3,
@ -3246,15 +3315,20 @@ pre {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* Status / empty-state copy only — conceptual docs go in HelpHint on the title. */
.panel__hint { .panel__hint {
margin: 0 0 12px; margin: 0 0 10px;
font-size: 13px; font-size: 12px;
color: #94a3b8; color: var(--nm-ops-muted, #9aa9bc);
line-height: 1.45; line-height: 1.4;
} }
.panel__hint--live { .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 { .collect-commands {

View file

@ -2,6 +2,7 @@ import { Button, Checkbox, Input, Modal } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { HelpHint } from "../components/HelpHint";
import { AppModalShell } from "../components/ui/AppModalShell"; import { AppModalShell } from "../components/ui/AppModalShell";
import { FieldSelect } from "../components/ui/FieldSelect"; import { FieldSelect } from "../components/ui/FieldSelect";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
@ -226,14 +227,9 @@ export function ApiTokensPage() {
<div className="page-stack system-page"> <div className="page-stack system-page">
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2 className="token-page__title"> <h2 className="token-page__title card-title-with-hint">
{t("auth.apiKeysTitle")} {t("auth.apiKeysTitle")}
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp", { max: tokenMax || 20 })}> <HelpHint text={t("auth.apiKeysHelp", { max: tokenMax || 20 })} ariaLabel={t("common.help")} />
?
<span className="help-q__tip" role="tooltip">
{t("auth.apiKeysHelp", { max: tokenMax || 20 })}
</span>
</span>
</h2> </h2>
{tokenMax > 0 ? ( {tokenMax > 0 ? (
<span className="muted" style={{ fontSize: 13 }}> <span className="muted" style={{ fontSize: 13 }}>

View file

@ -639,7 +639,6 @@ export function CollectPage() {
/> />
</label> </label>
</div> </div>
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
{policyScopeMode === "selected" ? ( {policyScopeMode === "selected" ? (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
@ -817,7 +816,6 @@ export function CollectPage() {
<Modal.CloseTrigger isDisabled={createMutation.isPending} /> <Modal.CloseTrigger isDisabled={createMutation.isPending} />
</Modal.Header> </Modal.Header>
<Modal.Body className="flex flex-col gap-3"> <Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("collect.create.hint")}</p>
<div className="collect-create-modal"> <div className="collect-create-modal">
<div className="form-grid form-grid--single"> <div className="form-grid form-grid--single">
<label className="form-grid__full"> <label className="form-grid__full">
@ -839,7 +837,6 @@ export function CollectPage() {
/> />
</label> </label>
</div> </div>
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
<div className="collect-selected-block"> <div className="collect-selected-block">
<div className="collect-selected-block__head"> <div className="collect-selected-block__head">
@ -854,7 +851,7 @@ export function CollectPage() {
) : null} ) : null}
</div> </div>
{selectedList.length === 0 ? ( {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"> <div className="collect-selected-list">
{selectedList.map((row) => { {selectedList.map((row) => {
@ -884,7 +881,6 @@ export function CollectPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<div> <div>
<h3>{t("collect.create.pickTitle")}</h3> <h3>{t("collect.create.pickTitle")}</h3>
<p className="panel__hint">{t("collect.create.pickHint")}</p>
</div> </div>
<div className="table-actions"> <div className="table-actions">
<Button <Button

View file

@ -1,5 +1,6 @@
import { Button } from "@heroui/react"; import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { HelpHint } from "../components/HelpHint";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { apiDelete, apiGet, apiPost } from "../services/api"; import { apiDelete, apiGet, apiPost } from "../services/api";
@ -81,9 +82,11 @@ export function SessionsPage() {
<div className="page-stack system-page"> <div className="page-stack system-page">
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <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> </div>
<p className="panel__hint">{t("auth.sessionsHint")}</p>
<div className="pt-list"> <div className="pt-list">
<div className="filter-inline"> <div className="filter-inline">
<Button <Button

View file

@ -873,7 +873,6 @@ export function UmePage() {
{t("ume.cli.showPanel")} {t("ume.cli.showPanel")}
</Button> </Button>
</div> </div>
<p className="ume-entry__summary muted">{t("ume.cli.hint")}</p>
</article> </article>
<article className="panel ume-entry nm-page-panel"> <article className="panel ume-entry nm-page-panel">
@ -966,7 +965,6 @@ export function UmePage() {
<Modal.CloseTrigger /> <Modal.CloseTrigger />
</Modal.Header> </Modal.Header>
<Modal.Body className="flex flex-col gap-3"> <Modal.Body className="flex flex-col gap-3">
<p className="muted">{t("ume.cli.hint")}</p>
<UmeCliConnectPanel embedded enabled /> <UmeCliConnectPanel embedded enabled />
</Modal.Body> </Modal.Body>
</AppModalShell> </AppModalShell>

View file

@ -69,7 +69,6 @@ export function UsersPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("auth.usersTitle")}</h2> <h2>{t("auth.usersTitle")}</h2>
</div> </div>
<p className="panel__hint">{t("auth.usersHint")}</p>
<div className="pt-list"> <div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}> <form className="filter-inline" onSubmit={onCreate}>

View file

@ -97,7 +97,6 @@ export function TaskOverviewPage() {
{query.isFetching ? t("common.refreshing") : t("common.refresh")} {query.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button> </Button>
</div> </div>
<p className="panel__hint">{t("audit.tasks.hint")}</p>
<div className="pt-list-kpis"> <div className="pt-list-kpis">
<div className="pt-list-kpi pt-list-kpi--live"> <div className="pt-list-kpi pt-list-kpi--live">

View file

@ -2498,9 +2498,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
{activeTab === "templates" ? ( {activeTab === "templates" ? (
<div className="pt-list-table-wrap"> <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"> <table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>

View file

@ -1,6 +1,6 @@
import { Button, Input, Modal } from "@heroui/react"; import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell"; import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect"; import { FieldSelect } from "../../components/ui/FieldSelect";
@ -383,6 +383,7 @@ function fmtTime(v?: string | null) {
export function BizMigrationPage() { export function BizMigrationPage() {
const { t } = useI18n(); const { t } = useI18n();
const navigate = useNavigate();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@ -1694,17 +1695,25 @@ export function BizMigrationPage() {
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("bizMigration.title")}</h2> <h2>{t("bizMigration.title")}</h2>
<div className="btn-row"> <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}> <Button size="sm" variant="primary" onPress={openCreate}>
{t("bizMigration.create")} {t("bizMigration.create")}
</Button> </Button>
</div> </div>
</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">
<div className="pt-list-kpis"> <div className="pt-list-kpis">

View file

@ -1,6 +1,7 @@
import { Button, Input, Modal } from "@heroui/react"; import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect"; import { FieldSelect } from "../../components/ui/FieldSelect";
import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useDebouncedValue } from "../../hooks/useDebouncedValue";
@ -806,6 +807,7 @@ function DualPatternList({
export function BizMonitorTemplatesPage() { export function BizMonitorTemplatesPage() {
const { t } = useI18n(); const { t } = useI18n();
const navigate = useNavigate();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const [items, setItems] = useState<MonitorTpl[]>([]); const [items, setItems] = useState<MonitorTpl[]>([]);
@ -1105,7 +1107,10 @@ export function BizMonitorTemplatesPage() {
return ( return (
<section className="panel nm-page-panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <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"> <div className="btn-row">
<input <input
ref={tplImportRef} ref={tplImportRef}
@ -1117,6 +1122,13 @@ export function BizMonitorTemplatesPage() {
if (f) void importTemplateFile(f); if (f) void importTemplateFile(f);
}} }}
/> />
<Button
size="sm"
variant="secondary"
onPress={() => navigate("/network/cutover/compare-templates")}
>
{t("bizMonitorTpl.openCompare")}
</Button>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@ -1130,12 +1142,6 @@ export function BizMonitorTemplatesPage() {
</Button> </Button>
</div> </div>
</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">
<div className="pt-list-kpis"> <div className="pt-list-kpis">