mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 13:20:52 +08:00
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:
parent
8ecbb13f52
commit
ec19102446
11 changed files with 109 additions and 93 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ const managedNe = {
|
|||
loadingIds: "加载中…",
|
||||
noMatchingNe: "所选范围内没有网元",
|
||||
scopeCount: "将作用于 {{n}} 台网元",
|
||||
cardFilterHint: "点击卡片可按标签筛选下方列表",
|
||||
},
|
||||
col: {
|
||||
name: "名称",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue