mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +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 { 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 }}>
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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: "连通性测试中…",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue