Add topology NE create/connect flows and trim connect-test logs.

Bring full managed-NE create onto the canvas (keep placeholders), add single/multi connect test and multi discover links, split topology/managedNe dialogs into modules, and shorten redundant connectivity failure details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-01 20:37:42 +08:00
parent f8d4a396f1
commit 56569e49d1
19 changed files with 1980 additions and 931 deletions

View file

@ -0,0 +1,71 @@
import { useMutation } from "@tanstack/react-query";
import { connectTestManagedNe } from "../../services/api";
import { useI18n } from "../../i18n";
import { formatSystemTime } from "../../utils/time";
import type { ManagedNeItem } from "../../types";
import { connectPillLevel } from "./connectStatus";
export type ManagedNeConnectDetailDialogProps = {
row: ManagedNeItem | null;
onClose: () => void;
/** Called after a retest is submitted so parent can refresh/poll. */
onRetestSubmitted?: (rowId: string) => void;
};
export function ManagedNeConnectDetailDialog({
row,
onClose,
onRetestSubmitted,
}: ManagedNeConnectDetailDialogProps) {
const { t } = useI18n();
const connectMutation = useMutation({
mutationFn: connectTestManagedNe,
onSuccess: () => {
if (row) onRetestSubmitted?.(row.id);
},
});
if (!row) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("managedNe.connectDetailTitle")}</h3>
<p className="form-hint">
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
{row.connect_tested_at
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<p>
<span className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}>
{row.connect_status}
</span>
{row.connect_message ? (
<span className="connect-detail-summary"> — {row.connect_message}</span>
) : null}
</p>
<pre className="connect-log">
{row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")}
</pre>
<div className="modal__actions">
<button
type="button"
disabled={connectMutation.isPending}
onClick={() => connectMutation.mutate([row.id])}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</button>
<button type="button" onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
</div>
{connectMutation.isError ? (
<p className="form-hint" role="alert">
{String(connectMutation.error)}
</p>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,273 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api";
import { HopProxyFields } from "../../components/HopProxyFields";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { patchHopVendorChange } from "../../utils/hopProxy";
import type { ManagedNeItem } from "../../types";
import {
applyHopTemplate,
buildManagedNeSaveBody,
emptyManagedNeForm,
formFromManagedNe,
type ManagedNeFormState,
} from "./formState";
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
export type ManagedNeFormDialogProps = {
open: boolean;
/** null = create mode */
editing: ManagedNeItem | null;
/** Prefill when creating (e.g. topology deep-link fields). Ignored when editing. */
initialValues?: Partial<ManagedNeFormState>;
onClose: () => void;
onSaved: (item: ManagedNeItem) => void;
};
export function ManagedNeFormDialog({
open,
editing,
initialValues,
onClose,
onSaved,
}: ManagedNeFormDialogProps) {
const { t } = useI18n();
const [form, setForm] = useState<ManagedNeFormState>(emptyManagedNeForm);
const metaQuery = useQuery({
queryKey: queryKeys.managedNeMeta,
queryFn: fetchManagedNeMeta,
staleTime: 60_000,
enabled: open,
});
// Reset only when the dialog opens or the edited row changes — avoid clobbering
// in-progress edits if the parent re-renders with a new initialValues object identity.
const editingId = editing?.id ?? "";
useEffect(() => {
if (!open) return;
if (editing) {
setForm(formFromManagedNe(editing));
return;
}
setForm({ ...emptyManagedNeForm(), ...(initialValues || {}) });
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create
}, [open, editingId]);
const vendors = metaQuery.data?.vendors ?? [];
const deviceTypes = useMemo(() => {
const base = metaQuery.data?.device_types ?? [];
const cur = String(form.device_type || "").trim();
if (cur && !base.includes(cur)) return [cur, ...base];
return base;
}, [metaQuery.data?.device_types, form.device_type]);
const saveMutation = useMutation({
mutationFn: async () => {
const body = buildManagedNeSaveBody(form, {
editing: Boolean(editing),
hopHostRequired: t("managedNe.hop.hostRequired"),
hopUserRequired: t("managedNe.hop.userRequired"),
hopPasswordRequired: t("managedNe.hop.passwordRequired"),
});
if (editing) {
return updateManagedNe(editing.id, body);
}
return createManagedNe(body);
},
onSuccess: (item) => {
onSaved(item);
},
});
if (!open) return null;
return (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
if (saveMutation.isPending) return;
onClose();
}}
>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.name")}</FormLabel>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
</label>
<label>
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
<select
required
value={form.vendor}
onChange={(e) => {
const vendor = e.target.value;
setForm((prev) => {
const next = { ...prev, vendor };
const dt = String(prev.device_type || "").trim().toLowerCase();
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
if (vendor === "ZTE") next.device_type = "zte_zxros";
else if (vendor === "Huawei") next.device_type = "huawei";
else if (vendor === "Cisco") next.device_type = "cisco_ios";
else if (vendor === "Juniper") next.device_type = "juniper_junos";
else if (vendor === "Nokia") next.device_type = "nokia_sros";
}
return next;
});
}}
>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
<select
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
<input
required
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.port")}</FormLabel>
<input
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</label>
<label>
<FormLabel>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
</FormLabel>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
</label>
<label className="form-grid__full">
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
</label>
</div>
<fieldset className="form-fieldset form-grid__full">
<legend>{t("managedNe.hop.sectionTitle")}</legend>
<label className="form-check">
<input
type="checkbox"
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
...patchHopVendorChange(prev.hop_vendor, prev),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
}
: {}),
}));
}}
/>
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
{form.hop_enabled ? (
<HopProxyFields
value={{
hop_vendor: form.hop_vendor,
hop_host: form.hop_host,
hop_port: form.hop_port,
hop_protocol: form.hop_protocol,
hop_username: form.hop_username,
hop_password: form.hop_password,
hop_command_template: form.hop_command_template,
hop_vrf: form.hop_vrf,
hop_target_auth_mode: form.hop_target_auth_mode,
hop_enter_system_view: form.hop_enter_system_view,
}}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
hopPasswordRequired={!editing}
hopPasswordOptional={Boolean(editing)}
/>
) : null}
</fieldset>
<div className="modal__actions">
<button type="button" disabled={saveMutation.isPending} onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
<button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
</div>
{saveMutation.isError ? (
<p className="form-hint" role="alert">
{String(saveMutation.error)}
</p>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
export function connectStatusClass(status: string): string {
if (status === "pass") return "pt-list-status--ok";
if (status === "fail") return "pt-list-status--failed";
if (status === "testing") return "pt-list-status--running";
return "pt-list-status--unknown";
}
export function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
const s = String(status || "").toLowerCase();
if (s === "pass" || s === "ok") return "up";
if (s === "fail" || s === "error") return "down";
if (s === "testing") return "warn";
return "unknown";
}

View file

@ -0,0 +1,167 @@
import {
defaultHopTemplate,
isAutoHopTemplate,
type HopVendor,
} from "../../utils/hopProxy";
import type { ManagedNeItem } from "../../types";
export type ManagedNeFormState = {
name: string;
vendor: string;
device_type: string;
ip_address: string;
port: number;
protocol: string;
username: string;
password: string;
tags: string;
remark: string;
hop_enabled: boolean;
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: "bastion_managed" | "manual";
hop_enter_system_view: boolean;
};
export function deviceTypeForVendor(vendor: string): string {
if (vendor === "ZTE") return "zte_zxros";
if (vendor === "Huawei") return "huawei";
if (vendor === "Cisco") return "cisco_ios";
if (vendor === "Juniper") return "juniper_junos";
if (vendor === "Nokia") return "nokia_sros";
return "generic";
}
export function emptyManagedNeForm(): ManagedNeFormState {
return {
name: "",
vendor: "ZTE",
device_type: "zte_zxros",
ip_address: "",
port: 22,
protocol: "ssh",
username: "",
password: "",
tags: "",
remark: "",
hop_enabled: false,
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 function applyHopTemplate(
prev: ManagedNeFormState,
protocol: string,
vrf: string,
force = false,
): Partial<ManagedNeFormState> {
if (
!force &&
!isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)
) {
return {};
}
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
}
export function formFromManagedNe(row: ManagedNeItem): ManagedNeFormState {
const hopVendor = (["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor)
? row.hop_vendor
: "zte") as HopVendor;
return {
name: row.name,
vendor: row.vendor,
device_type: row.device_type,
ip_address: row.ip_address,
port: row.port,
protocol: row.protocol,
username: row.username,
password: "",
tags: row.tags,
remark: row.remark,
hop_enabled: row.hop_enabled,
hop_vendor: hopVendor,
hop_host: row.hop_host,
hop_port: row.hop_port,
hop_protocol: row.hop_protocol,
hop_username: row.hop_username,
hop_password: "",
hop_command_template: isAutoHopTemplate(
row.hop_command_template,
row.hop_vendor,
row.hop_protocol,
row.hop_vrf,
)
? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf)
: row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf),
hop_vrf: row.hop_vrf,
hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
hop_enter_system_view: Boolean(row.hop_enter_system_view),
};
}
export function managedSourceKey(
source: string | undefined,
): "manual" | "ume_sync" | "webcrt" | "lldp" | "topology" | "" {
const s = String(source || "").trim().toLowerCase();
if (!s) return "manual";
if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s;
return "";
}
/** Build API body + hop validation. Throws Error with message key text already translated by caller. */
export function buildManagedNeSaveBody(
form: ManagedNeFormState,
opts: { editing: boolean; hopHostRequired: string; hopUserRequired: string; hopPasswordRequired: string },
): Record<string, unknown> {
const body: Record<string, unknown> = {
name: form.name,
vendor: form.vendor,
device_type: form.device_type,
ip_address: form.ip_address,
port: form.port,
protocol: form.protocol,
username: form.username,
tags: form.tags,
remark: form.remark,
hop_enabled: form.hop_enabled,
hop_vendor: form.hop_vendor,
hop_host: form.hop_host,
hop_port: form.hop_port,
hop_protocol: form.hop_protocol,
hop_username: form.hop_username,
hop_command_template: form.hop_command_template,
hop_vrf: form.hop_vrf,
hop_target_auth_mode: form.hop_target_auth_mode,
hop_enter_system_view: form.hop_enter_system_view,
...(form.password ? { password: form.password } : {}),
...(form.hop_password ? { hop_password: form.hop_password } : {}),
};
if (form.hop_enabled) {
if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired);
if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired);
if (!opts.editing && !form.hop_password) throw new Error(opts.hopPasswordRequired);
}
if (opts.editing) {
if (!form.password) delete body.password;
if (!form.hop_password) delete body.hop_password;
} else {
body.password = form.password || "";
}
return body;
}