diff --git a/web/src/components/HelpHint.tsx b/web/src/components/HelpHint.tsx
index 11c5c8c..e39287e 100644
--- a/web/src/components/HelpHint.tsx
+++ b/web/src/components/HelpHint.tsx
@@ -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(
+
+ {body}
+ ,
+ );
+ 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
{renderInline(text)}
;
+ }
+ return (
+
+ {blocks.map((b, i) => {
+ if (b.kind === "title") {
+ return (
+
+ {b.text}
+
+ );
+ }
+ if (b.kind === "list") {
+ return (
+
+ {b.items.map((item, j) => (
+ - {renderInline(item)}
+ ))}
+
+ );
+ }
+ return (
+
+ {renderInline(b.text)}
+
+ );
+ })}
+
+ );
+}
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
const [open, setOpen] = useState(false);
const [popoverStyle, setPopoverStyle] = useState({});
const rootRef = useRef(null);
const popoverRef = useRef(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}
+
,
document.body,
)
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index 6e10532..1ad92c0 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -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",
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index d4e7a84..c770ea1 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -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: "配置名称",
diff --git a/web/src/index.css b/web/src/index.css
index 9d2866c..b257605 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -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 {
diff --git a/web/src/pages/ApiTokensPage.tsx b/web/src/pages/ApiTokensPage.tsx
index 4a92837..94e5472 100644
--- a/web/src/pages/ApiTokensPage.tsx
+++ b/web/src/pages/ApiTokensPage.tsx
@@ -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() {
-
+
{t("auth.apiKeysTitle")}
-
- ?
-
- {t("auth.apiKeysHelp", { max: tokenMax || 20 })}
-
-
+
{tokenMax > 0 ? (
diff --git a/web/src/pages/CollectPage.tsx b/web/src/pages/CollectPage.tsx
index ebddf82..fdd03e1 100644
--- a/web/src/pages/CollectPage.tsx
+++ b/web/src/pages/CollectPage.tsx
@@ -639,7 +639,6 @@ export function CollectPage() {
/>
- {t("collect.form.commandsHint")}
{policyScopeMode === "selected" ? (
@@ -817,7 +816,6 @@ export function CollectPage() {
- {t("collect.create.hint")}
-
{t("collect.form.commandsHint")}
@@ -854,7 +851,7 @@ export function CollectPage() {
) : null}
{selectedList.length === 0 ? (
-
{t("collect.create.selectedEmpty")}
+
{t("collect.create.selectedEmpty")}
) : (
{selectedList.map((row) => {
@@ -884,7 +881,6 @@ export function CollectPage() {
{t("collect.create.pickTitle")}
-
{t("collect.create.pickHint")}