From b5da5948a8d8819e2fca4dea458bbe504ce1c109 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 12 Sep 2026 17:38:27 +0800 Subject: [PATCH] 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 --- web/src/components/UmeCliConnectPanel.tsx | 147 +++++++++++++++++++--- web/src/i18n/en.ts | 6 +- web/src/i18n/zh.ts | 6 +- web/src/index.css | 90 ++++++++++++- 4 files changed, 221 insertions(+), 28 deletions(-) diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx index 2e173f9..8ecdc51 100644 --- a/web/src/components/UmeCliConnectPanel.tsx +++ b/web/src/components/UmeCliConnectPanel.tsx @@ -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(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(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('[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

{t("ume.cli.connectTestTitle")}

-
- setSampleUmeNeId(e.target.value)} - placeholder={t("ume.cli.sampleNePh")} - /> - +
+
+
+ { + 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} + /> +
+ +
+ {sampleOpen ? ( +
+ {sampleNeQuery.isLoading ? ( +
{t("ume.cli.sampleNeLoading")}
+ ) : sampleHits.length === 0 ? ( +
{t("ume.cli.sampleNeEmpty")}
+ ) : ( + 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 ( + + ); + }) + )} +
+ ) : null}
{testNeId ? (
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index cc9d7c6..19e4772 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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…", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 521356c..c5b98d0 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "连通性测试中…", diff --git a/web/src/index.css b/web/src/index.css index 95b1600..c9bfc21 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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));