Fix NE page lint errors, topology tree overflow and NE row height.

- NE page: derive the live connect-detail row instead of syncing it in an
  effect; one async path for topology deep links; memoize per-tag stats;
  re-seed the form dialog during render on open; move hop form state into
  lib/hopProxy so the component file only exports components.
- NE page: drop the "click a card to filter" hint; keep row actions on one
  line so rows no longer double in height.
- Topology tree: rows fit the sidebar, long names ellipsize with a tooltip,
  node counts stay visible; default sidebar width 260 -> 280.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-13 01:51:37 +08:00
parent 8ecbb13f52
commit ec19102446
11 changed files with 109 additions and 93 deletions

View file

@ -21,7 +21,8 @@ import {
} from "../../services/api";
import { HelpHint } from "../../components/HelpHint";
import { ListPager } from "../../components/ListPager";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./components/HopProxyFields";
import { HopProxyFields } from "./components/HopProxyFields";
import { emptyHopProxyFields, type HopProxyFieldsState } from "./lib/hopProxy";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
@ -145,19 +146,14 @@ export function NePage() {
},
});
useEffect(() => {
if (!connectDetailRow) return;
const updated = listQuery.data?.items?.find((x) => x.id === connectDetailRow.id);
if (!updated) return;
if (
updated.connect_status !== connectDetailRow.connect_status ||
updated.connect_message !== connectDetailRow.connect_message ||
updated.connect_detail !== connectDetailRow.connect_detail ||
updated.connect_tested_at !== connectDetailRow.connect_tested_at
) {
setConnectDetailRow(updated);
}
}, [listQuery.data, connectDetailRow]);
// The detail dialog follows the list's latest copy of the row (status polling).
const connectDetailLive = useMemo(
() =>
connectDetailRow
? (listQuery.data?.items?.find((x) => x.id === connectDetailRow.id) ?? connectDetailRow)
: null,
[listQuery.data, connectDetailRow],
);
const total = listQuery.data?.total ?? 0;
const pages = pageCount(total, pageSize);
@ -341,34 +337,30 @@ export function NePage() {
);
};
if (neId) {
void (async () => {
try {
const row = await fetchManagedNeById(neId);
openEdit(row);
} catch (err) {
showError(managedNeErrorText(err, t));
} finally {
clearDeepLink();
void (async () => {
try {
if (neId) {
openEdit(await fetchManagedNeById(neId));
return;
}
})();
return;
}
const name = String(searchParams.get("name") || "").trim();
const ip = String(searchParams.get("ip_address") || "").trim();
const vendorRaw = String(searchParams.get("vendor") || "").trim();
const base = emptyManagedNeForm();
const vendor = vendorRaw || base.vendor;
setEditing(null);
setFormSeed({
name,
vendor,
device_type: deviceTypeForVendor(vendor),
ip_address: ip,
});
setModalOpen(true);
clearDeepLink();
const name = String(searchParams.get("name") || "").trim();
const ip = String(searchParams.get("ip_address") || "").trim();
const vendorRaw = String(searchParams.get("vendor") || "").trim();
const vendor = vendorRaw || emptyManagedNeForm().vendor;
setEditing(null);
setFormSeed({
name,
vendor,
device_type: deviceTypeForVendor(vendor),
ip_address: ip,
});
setModalOpen(true);
} catch (err) {
showError(managedNeErrorText(err, t));
} finally {
clearDeepLink();
}
})();
}, [searchParams, setSearchParams, showError, t]);
const toggleSelect = (id: string) => {
@ -386,7 +378,7 @@ export function NePage() {
const stats: ManagedNeStats | undefined = statsQuery.data;
const statTags: string[] = stats?.tags ?? [];
const perTag = stats?.per_tag ?? {};
const perTag = useMemo(() => stats?.per_tag ?? {}, [stats]);
const tagCardItems = useMemo(() => {
if (!stats) return [];
const items: Array<{ key: string; title: string; total: number; by_status: Record<string, number> }> = [];
@ -525,7 +517,6 @@ export function NePage() {
</div>
{stats ? (
<>
<p className="muted text-sm">{t("managedNe.stats.cardFilterHint")}</p>
<div className="ne-stats-card__tag-cards">
{tagCardItems.map((x) => (
<button
@ -1098,7 +1089,7 @@ export function NePage() {
</AppModalShell>
<ManagedNeConnectDetailDialog
row={connectDetailRow}
row={connectDetailLive}
onClose={() => setConnectDetailRow(null)}
onRetestSubmitted={() => {
void invalidateList();

View file

@ -9,35 +9,12 @@ import {
isBastionHopVendor,
isLinuxHopVendor,
patchHopVendorChange,
type HopProxyFieldsState,
type HopTargetAuthMode,
type HopVendor,
} from "../lib/hopProxy";
export type HopProxyFieldsState = {
hop_vendor: HopVendor;
hop_host: string;
hop_port: number;
hop_protocol: string;
hop_username: string;
hop_password: string;
hop_command_template: string;
hop_vrf: string;
hop_target_auth_mode: HopTargetAuthMode;
hop_enter_system_view: boolean;
};
export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_vendor: "zte",
hop_host: "",
hop_port: 22,
hop_protocol: "ssh",
hop_username: "",
hop_password: "",
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
hop_vrf: "",
hop_target_auth_mode: "bastion_managed",
hop_enter_system_view: false,
});
export type { HopProxyFieldsState };
function applyHopTemplate(
prev: HopProxyFieldsState,

View file

@ -89,16 +89,25 @@ export function ManagedNeFormDialog({
},
});
// Re-seed the form once each time the dialog opens (or switches row). Done
// during render — React's pattern for resetting state when inputs change.
const editingId = editing?.id ?? "";
const seedKey = open ? `open:${editingId}` : "";
const [seededKey, setSeededKey] = useState("");
if (seedKey !== seededKey) {
setSeededKey(seedKey);
if (open) {
const seed = editing ? formFromManagedNe(editing) : { ...emptyManagedNeForm(), ...(initialValues || {}) };
setForm(seed);
setPortDraft(String(seed.port));
setSubmitted(false);
}
}
// The mutation's error/result belongs to the previous opening.
const resetSave = saveMutation.reset;
useEffect(() => {
if (!open) return;
const seed = editing ? formFromManagedNe(editing) : { ...emptyManagedNeForm(), ...(initialValues || {}) };
setForm(seed);
setPortDraft(String(seed.port));
setSubmitted(false);
saveMutation.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create
}, [open, editingId]);
if (open) resetSave();
}, [open, editingId, resetSave]);
return (
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">

View file

@ -163,3 +163,30 @@ export function patchHopVendorChange(
}
export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate };
/** Form state shared by the hop/proxy fields (NE form, UME CLI connect). */
export type HopProxyFieldsState = {
hop_vendor: HopVendor;
hop_host: string;
hop_port: number;
hop_protocol: string;
hop_username: string;
hop_password: string;
hop_command_template: string;
hop_vrf: string;
hop_target_auth_mode: HopTargetAuthMode;
hop_enter_system_view: boolean;
};
export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_vendor: "zte",
hop_host: "",
hop_port: 22,
hop_protocol: "ssh",
hop_username: "",
hop_password: "",
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
hop_vrf: "",
hop_target_auth_mode: "bastion_managed",
hop_enter_system_view: false,
});

View file

@ -112,7 +112,7 @@ function ViewRow({
>
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={13} />
</span>
<span className="topo-map-list__title">
<span className="topo-map-list__title" title={displayViewName(v.name, t)}>
{displayViewName(v.name, t)}
{viewActive && dirty ? " *" : ""}
</span>
@ -249,7 +249,7 @@ export function TopologyTreeNavFolder({
<span className="topo-region-list__glyph" aria-hidden="true">
<RegionGlyph size={14} />
</span>
<span className="topo-map-list__title">{regionDisplayName(folder, t)}</span>
<span className="topo-map-list__title" title={regionDisplayName(folder, t)}>{regionDisplayName(folder, t)}</span>
<span className="topo-map-list__count">{formatNeCount(folderNeCount(folder))}</span>
</span>
</button>

View file

@ -4,7 +4,7 @@ export const WORLD_MAP_ENABLED = false;
export const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
export const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
export const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth";
export const SIDEBAR_WIDTH_DEFAULT = 260;
export const SIDEBAR_WIDTH_DEFAULT = 280;
export const SIDEBAR_WIDTH_MIN = 180;
export const SIDEBAR_WIDTH_MAX = 560;

View file

@ -12,7 +12,8 @@ import {
fetchUmeNe,
postUmeConnectTest,
} from "../../../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../../managed-ne/components/HopProxyFields";
import { HopProxyFields } from "../../managed-ne/components/HopProxyFields";
import { emptyHopProxyFields, type HopProxyFieldsState } from "../../managed-ne/lib/hopProxy";
import { HelpHint } from "../../../components/HelpHint";
import { FieldSelect } from "../../../components/ui/FieldSelect";
import { queryKeys } from "../../../constants/queryKeys";

View file

@ -44,7 +44,6 @@ const managedNe = {
loadingIds: "Loading…",
noMatchingNe: "No NEs in the selected scope",
scopeCount: "Applies to {{n}} NEs",
cardFilterHint: "Click a card to filter the list below by tag",
},
col: {
name: "Name",

View file

@ -43,7 +43,6 @@ const managedNe = {
loadingIds: "加载中…",
noMatchingNe: "所选范围内没有网元",
scopeCount: "将作用于 {{n}} 台网元",
cardFilterHint: "点击卡片可按标签筛选下方列表",
},
col: {
name: "名称",

View file

@ -1280,3 +1280,14 @@ button.ne-tag-card.is-active {
padding: 8px 10px;
background: rgb(var(--nx-accent-rgb) / 0.06);
}
/* NE list: row actions stay on one line (the table scrolls sideways if it must)
instead of wrapping and doubling the row height. */
.ne-page .pt-list-table .pt-list-actions {
flex-wrap: nowrap;
gap: 6px;
}
.ne-page .pt-list-table td:has(> .pt-list-actions) {
white-space: nowrap;
}

View file

@ -2,7 +2,7 @@
/* ---- Topology — dark sidebar + canvas (shared chrome tokens) ---- */
.topo-page {
--topo-sidebar-w: 260px;
--topo-sidebar-w: 280px;
display: grid;
grid-template-columns: var(--topo-sidebar-w) 1fr;
gap: 12px;
@ -1243,10 +1243,11 @@
flex-direction: column;
gap: 2px;
padding: 2px 0;
/* Allow deep indent + actions to extend; parent scroll shows a horizontal bar. */
width: max-content;
min-width: 100%;
max-width: none;
/* Rows fit the sidebar: long names ellipsize (full name on hover), counts
stay visible on the right — no horizontal scrollbar. */
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
@ -1254,7 +1255,7 @@
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow-x: auto;
overflow-x: hidden;
overflow-y: auto;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 6px;
@ -1432,10 +1433,10 @@
}
.topo-region-list .topo-map-list__title {
min-width: max-content;
min-width: 0;
flex: 0 1 auto;
overflow: visible;
text-overflow: clip;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
@ -1449,7 +1450,8 @@
.topo-region-list .topo-map-list__count {
flex: 0 0 auto;
margin-left: 4px;
margin-left: auto;
padding-left: 6px;
padding: 0;
border-radius: 0;
font-size: 11px;