mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 12:40:44 +08:00
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:
parent
cd6e1f1069
commit
b5da5948a8
4 changed files with 221 additions and 28 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button, Checkbox, Input, Label, TextField } from "@heroui/react";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
apiDelete,
|
apiDelete,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
fetchCliProfiles,
|
fetchCliProfiles,
|
||||||
fetchManagedNeMeta,
|
fetchManagedNeMeta,
|
||||||
fetchUmeCliOverride,
|
fetchUmeCliOverride,
|
||||||
|
fetchUmeNe,
|
||||||
postUmeConnectTest,
|
postUmeConnectTest,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
|
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
|
||||||
|
|
@ -17,7 +18,7 @@ import { FieldSelect } from "./ui/FieldSelect";
|
||||||
import { queryKeys } from "../constants/queryKeys";
|
import { queryKeys } from "../constants/queryKeys";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useToast } from "../hooks/useToast";
|
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 { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange } from "../utils/hopProxy";
|
||||||
import { formatSystemTime } from "../utils/time";
|
import { formatSystemTime } from "../utils/time";
|
||||||
|
|
||||||
|
|
@ -29,6 +30,14 @@ function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
|
||||||
return "unknown";
|
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 = {
|
type ProfileForm = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -97,10 +106,19 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [form, setForm] = useState<ProfileForm>(emptyForm);
|
const [form, setForm] = useState<ProfileForm>(emptyForm);
|
||||||
const [sampleUmeNeId, setSampleUmeNeId] = useState("");
|
const [sampleUmeNeId, setSampleUmeNeId] = useState("");
|
||||||
|
const [sampleFilter, setSampleFilter] = useState("");
|
||||||
|
const [sampleOpen, setSampleOpen] = useState(false);
|
||||||
|
const [debouncedFilter, setDebouncedFilter] = useState("");
|
||||||
const [testPending, setTestPending] = useState(false);
|
const [testPending, setTestPending] = useState(false);
|
||||||
|
const sampleSuggestRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const testNeId = sampleUmeNeId.trim();
|
const testNeId = sampleUmeNeId.trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedFilter(sampleFilter.trim()), 220);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [sampleFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTestPending(false);
|
setTestPending(false);
|
||||||
}, [testNeId]);
|
}, [testNeId]);
|
||||||
|
|
@ -108,6 +126,36 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
|
||||||
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta, enabled });
|
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta, enabled });
|
||||||
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, enabled });
|
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, enabled });
|
||||||
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles, 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({
|
const overrideQuery = useQuery({
|
||||||
queryKey: queryKeys.umeCliOverride(testNeId),
|
queryKey: queryKeys.umeCliOverride(testNeId),
|
||||||
queryFn: () => fetchUmeCliOverride(testNeId),
|
queryFn: () => fetchUmeCliOverride(testNeId),
|
||||||
|
|
@ -333,22 +381,85 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
|
||||||
</div>
|
</div>
|
||||||
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
|
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
|
||||||
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
|
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
|
||||||
<div className="cli-connect-test-row">
|
<div className="cli-connect-test">
|
||||||
<Input
|
<div className="cli-connect-test-row">
|
||||||
value={sampleUmeNeId}
|
<div className="cli-connect-test-pick">
|
||||||
onChange={(e) => setSampleUmeNeId(e.target.value)}
|
<Input
|
||||||
placeholder={t("ume.cli.sampleNePh")}
|
value={sampleFilter}
|
||||||
/>
|
onChange={(e) => {
|
||||||
<Button
|
setSampleFilter(e.target.value);
|
||||||
size="sm"
|
setSampleUmeNeId("");
|
||||||
variant="secondary"
|
setSampleOpen(true);
|
||||||
isDisabled={connectTestMutation.isPending}
|
}}
|
||||||
onPress={() => connectTestMutation.mutate()}
|
onFocus={() => setSampleOpen(true)}
|
||||||
>
|
onBlur={() => {
|
||||||
{connectTestMutation.isPending || overrideTesting
|
window.setTimeout(() => setSampleOpen(false), 160);
|
||||||
? t("managedNe.connect.running")
|
}}
|
||||||
: t("managedNe.connect.run")}
|
onKeyDown={(e) => {
|
||||||
</Button>
|
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>
|
</div>
|
||||||
{testNeId ? (
|
{testNeId ? (
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
|
|
|
||||||
|
|
@ -1254,8 +1254,10 @@ const en = {
|
||||||
deleted: "Profile deleted",
|
deleted: "Profile deleted",
|
||||||
saved: "CLI profile saved",
|
saved: "CLI profile saved",
|
||||||
connectTestTitle: "Sample NE connectivity test",
|
connectTestTitle: "Sample NE connectivity test",
|
||||||
sampleNePh: "ume_ne_id (copy from NE list below)",
|
sampleNePh: "Filter UME NEs (name / IP / ID)",
|
||||||
sampleNeRequired: "ume_ne_id is required",
|
sampleNeRequired: "Select a UME NE first",
|
||||||
|
sampleNeEmpty: "No matching NE",
|
||||||
|
sampleNeLoading: "Loading…",
|
||||||
connectTestSubmitted: "Connectivity test submitted",
|
connectTestSubmitted: "Connectivity test submitted",
|
||||||
connectTestWaiting: "Waiting for test result…",
|
connectTestWaiting: "Waiting for test result…",
|
||||||
connectTestRunning: "Connectivity test in progress…",
|
connectTestRunning: "Connectivity test in progress…",
|
||||||
|
|
|
||||||
|
|
@ -1246,8 +1246,10 @@ const zh = {
|
||||||
deleted: "配置已删除",
|
deleted: "配置已删除",
|
||||||
saved: "CLI 配置已保存",
|
saved: "CLI 配置已保存",
|
||||||
connectTestTitle: "样本网元连通性测试",
|
connectTestTitle: "样本网元连通性测试",
|
||||||
sampleNePh: "ume_ne_id(从下方网元清单复制)",
|
sampleNePh: "筛选 UME 网元(名称 / IP / ID)",
|
||||||
sampleNeRequired: "请填写 ume_ne_id",
|
sampleNeRequired: "请先选择一个 UME 网元",
|
||||||
|
sampleNeEmpty: "无匹配网元",
|
||||||
|
sampleNeLoading: "加载中…",
|
||||||
connectTestSubmitted: "已提交连通性测试",
|
connectTestSubmitted: "已提交连通性测试",
|
||||||
connectTestWaiting: "等待测试结果…",
|
connectTestWaiting: "等待测试结果…",
|
||||||
connectTestRunning: "连通性测试中…",
|
connectTestRunning: "连通性测试中…",
|
||||||
|
|
|
||||||
|
|
@ -2902,19 +2902,97 @@ pre {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cli-connect-test-row {
|
.cli-connect-test {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cli-connect-test-row input {
|
.cli-connect-test-row {
|
||||||
flex: 1 1 320px;
|
display: flex;
|
||||||
min-width: 240px;
|
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;
|
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 {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue