Make UME CLI sample NE test a filterable picker.

Pull top-5 matches from UME inventory, expand the list in-flow under the input, and auto-scroll the modal to keep results visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 17:38:27 +08:00
parent cd6e1f1069
commit b5da5948a8
4 changed files with 221 additions and 28 deletions

View file

@ -1,5 +1,5 @@
import { Button, Checkbox, Input, Label, TextField } from "@heroui/react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
apiDelete,
@ -9,6 +9,7 @@ import {
fetchCliProfiles,
fetchManagedNeMeta,
fetchUmeCliOverride,
fetchUmeNe,
postUmeConnectTest,
} from "../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
@ -17,7 +18,7 @@ import { FieldSelect } from "./ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CliConnectProfileItem, UmeCliOverrideItem } from "../types";
import type { CliConnectProfileItem, UmeCliOverrideItem, UmeNeItem } from "../types";
import { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange } from "../utils/hopProxy";
import { formatSystemTime } from "../utils/time";
@ -29,6 +30,14 @@ function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
return "unknown";
}
function formatUmeNePickLabel(row: UmeNeItem): string {
const name = String(row.user_label || row.ne_name || row.host_name || "").trim();
const ip = String(row.ip_address || "").trim();
const id = String(row.ne_id || "").trim();
const head = [name, ip].filter(Boolean).join(" · ");
return head ? `${head} · ${id}` : id;
}
type ProfileForm = {
id: string;
name: string;
@ -97,10 +106,19 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
const queryClient = useQueryClient();
const [form, setForm] = useState<ProfileForm>(emptyForm);
const [sampleUmeNeId, setSampleUmeNeId] = useState("");
const [sampleFilter, setSampleFilter] = useState("");
const [sampleOpen, setSampleOpen] = useState(false);
const [debouncedFilter, setDebouncedFilter] = useState("");
const [testPending, setTestPending] = useState(false);
const sampleSuggestRef = useRef<HTMLDivElement | null>(null);
const testNeId = sampleUmeNeId.trim();
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedFilter(sampleFilter.trim()), 220);
return () => window.clearTimeout(timer);
}, [sampleFilter]);
useEffect(() => {
setTestPending(false);
}, [testNeId]);
@ -108,6 +126,36 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta, enabled });
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, enabled });
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles, enabled });
const sampleNeQuery = useQuery({
queryKey: queryKeys.umeNE(debouncedFilter, 1, 5),
queryFn: () => fetchUmeNe({ keyword: debouncedFilter, page: 1, pageSize: 5 }),
enabled: enabled && sampleOpen,
staleTime: 5000,
});
const sampleHits = useMemo(
() => (sampleNeQuery.data?.items || []).slice(0, 5),
[sampleNeQuery.data],
);
useEffect(() => {
if (!sampleOpen) return;
const el = sampleSuggestRef.current;
if (!el) return;
const timer = window.setTimeout(() => {
const body = el.closest<HTMLElement>('[data-slot="modal-body"], .modal__body');
if (body) {
const elRect = el.getBoundingClientRect();
const bodyRect = body.getBoundingClientRect();
const overflow = elRect.bottom - bodyRect.bottom + 16;
if (overflow > 0) {
body.scrollBy({ top: overflow, behavior: "smooth" });
return;
}
}
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, 50);
return () => window.clearTimeout(timer);
}, [sampleOpen, sampleHits.length, sampleNeQuery.isFetching]);
const overrideQuery = useQuery({
queryKey: queryKeys.umeCliOverride(testNeId),
queryFn: () => fetchUmeCliOverride(testNeId),
@ -333,22 +381,85 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
</div>
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
<div className="cli-connect-test-row">
<Input
value={sampleUmeNeId}
onChange={(e) => setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")}
/>
<Button
size="sm"
variant="secondary"
isDisabled={connectTestMutation.isPending}
onPress={() => connectTestMutation.mutate()}
>
{connectTestMutation.isPending || overrideTesting
? t("managedNe.connect.running")
: t("managedNe.connect.run")}
</Button>
<div className="cli-connect-test">
<div className="cli-connect-test-row">
<div className="cli-connect-test-pick">
<Input
value={sampleFilter}
onChange={(e) => {
setSampleFilter(e.target.value);
setSampleUmeNeId("");
setSampleOpen(true);
}}
onFocus={() => setSampleOpen(true)}
onBlur={() => {
window.setTimeout(() => setSampleOpen(false), 160);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setSampleOpen(false);
(e.target as HTMLInputElement).blur();
}
if (e.key === "Enter" && sampleHits[0]) {
e.preventDefault();
const row = sampleHits[0];
setSampleUmeNeId(row.ne_id);
setSampleFilter(formatUmeNePickLabel(row));
setSampleOpen(false);
}
}}
placeholder={t("ume.cli.sampleNePh")}
aria-label={t("ume.cli.connectTestTitle")}
aria-autocomplete="list"
aria-expanded={sampleOpen}
/>
</div>
<Button
size="sm"
variant="secondary"
isDisabled={connectTestMutation.isPending || !testNeId}
onPress={() => connectTestMutation.mutate()}
>
{connectTestMutation.isPending || overrideTesting
? t("managedNe.connect.running")
: t("managedNe.connect.run")}
</Button>
</div>
{sampleOpen ? (
<div ref={sampleSuggestRef} className="cli-connect-test-suggest" role="listbox">
{sampleNeQuery.isLoading ? (
<div className="cli-connect-test-suggest__empty">{t("ume.cli.sampleNeLoading")}</div>
) : sampleHits.length === 0 ? (
<div className="cli-connect-test-suggest__empty">{t("ume.cli.sampleNeEmpty")}</div>
) : (
sampleHits.map((row) => {
const title =
String(row.user_label || row.ne_name || row.ne_id).trim() || row.ne_id;
const meta = [row.ip_address, row.ne_type].filter(Boolean).join(" · ");
return (
<button
key={row.ne_id}
type="button"
className={`cli-connect-test-suggest__item${
row.ne_id === sampleUmeNeId ? " is-active" : ""
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSampleUmeNeId(row.ne_id);
setSampleFilter(formatUmeNePickLabel(row));
setSampleOpen(false);
}}
>
<span className="cli-connect-test-suggest__name">{title}</span>
<span className="cli-connect-test-suggest__meta">
{meta || row.ne_id}
</span>
</button>
);
})
)}
</div>
) : null}
</div>
{testNeId ? (
<div style={{ marginTop: 12 }}>

View file

@ -1254,8 +1254,10 @@ const en = {
deleted: "Profile deleted",
saved: "CLI profile saved",
connectTestTitle: "Sample NE connectivity test",
sampleNePh: "ume_ne_id (copy from NE list below)",
sampleNeRequired: "ume_ne_id is required",
sampleNePh: "Filter UME NEs (name / IP / ID)",
sampleNeRequired: "Select a UME NE first",
sampleNeEmpty: "No matching NE",
sampleNeLoading: "Loading…",
connectTestSubmitted: "Connectivity test submitted",
connectTestWaiting: "Waiting for test result…",
connectTestRunning: "Connectivity test in progress…",

View file

@ -1246,8 +1246,10 @@ const zh = {
deleted: "配置已删除",
saved: "CLI 配置已保存",
connectTestTitle: "样本网元连通性测试",
sampleNePh: "ume_ne_id(从下方网元清单复制)",
sampleNeRequired: "请填写 ume_ne_id",
sampleNePh: "筛选 UME 网元(名称 / IP / ID)",
sampleNeRequired: "请先选择一个 UME 网元",
sampleNeEmpty: "无匹配网元",
sampleNeLoading: "加载中…",
connectTestSubmitted: "已提交连通性测试",
connectTestWaiting: "等待测试结果…",
connectTestRunning: "连通性测试中…",

View file

@ -2902,19 +2902,97 @@ pre {
margin-bottom: 0;
}
.cli-connect-test-row {
.cli-connect-test {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
}
.cli-connect-test-row input {
flex: 1 1 320px;
min-width: 240px;
.cli-connect-test-row {
display: flex;
flex-wrap: nowrap;
gap: 8px;
align-items: center;
width: 100%;
}
.cli-connect-test-pick {
position: relative;
flex: 1 1 auto;
min-width: 0;
}
.cli-connect-test-pick > input,
.cli-connect-test-pick [data-slot="input"],
.cli-connect-test-pick .input {
width: 100%;
margin-bottom: 0;
}
.cli-connect-test-suggest {
width: 100%;
margin: 0;
padding: 4px;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 6px;
background: rgba(2, 8, 23, 0.92);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.55);
overflow: visible;
}
.cli-connect-test-suggest__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
margin: 0;
padding: 8px 10px;
border: 0;
border-radius: 4px;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
}
.cli-connect-test-suggest__item + .cli-connect-test-suggest__item {
margin-top: 2px;
}
.cli-connect-test-suggest__item:hover,
.cli-connect-test-suggest__item.is-active {
background: rgba(56, 189, 248, 0.12);
}
.cli-connect-test-suggest__name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
color: #e2e8f0;
}
.cli-connect-test-suggest__meta {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
.cli-connect-test-suggest__empty {
padding: 10px 12px;
font-size: 12px;
color: #64748b;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));