mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 11:05:53 +08:00
Improve managed NE filters/validation and simplify the workbench UI.
Add column filters and clearer API error mapping for NE management, tighten port validation, and slim the workbench into reusable modules with cleaner styles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3c9e0efd2b
commit
312f6f2f96
25 changed files with 1484 additions and 2016 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -8,10 +8,13 @@ import { FieldSelect } from "../../../components/ui/FieldSelect";
|
|||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { patchHopVendorChange } from "../lib/hopProxy";
|
||||
import { managedNeErrorText } from "../lib/errors";
|
||||
import type { ManagedNeItem } from "../../../types";
|
||||
import {
|
||||
applyHopTemplate,
|
||||
buildManagedNeSaveBody,
|
||||
deviceTypeForVendor,
|
||||
isValidMgmtAddress,
|
||||
emptyManagedNeForm,
|
||||
formFromManagedNe,
|
||||
allowsOpenExecPolicy,
|
||||
|
|
@ -37,6 +40,9 @@ export function ManagedNeFormDialog({
|
|||
}: ManagedNeFormDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState<ManagedNeFormState>(emptyManagedNeForm);
|
||||
// Port is edited as text so the field can be cleared while typing.
|
||||
const [portDraft, setPortDraft] = useState("22");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const metaQuery = useQuery({
|
||||
queryKey: queryKeys.managedNeMeta,
|
||||
|
|
@ -45,16 +51,6 @@ export function ManagedNeFormDialog({
|
|||
enabled: open,
|
||||
});
|
||||
|
||||
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 execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
|
||||
|
|
@ -65,6 +61,15 @@ export function ManagedNeFormDialog({
|
|||
return base;
|
||||
}, [metaQuery.data?.device_types, form.device_type]);
|
||||
|
||||
const ipText = form.ip_address.trim();
|
||||
const portNum = /^\d+$/.test(portDraft.trim()) ? Number(portDraft.trim()) : NaN;
|
||||
const fieldErrors = {
|
||||
ip: !ipText ? t("managedNe.form.ipRequired") : !isValidMgmtAddress(ipText) ? t("managedNe.form.ipInvalid") : "",
|
||||
username: !form.username.trim() ? t("managedNe.form.usernameRequired") : "",
|
||||
port: portNum >= 1 && portNum <= 65535 ? "" : t("managedNe.form.portInvalid"),
|
||||
};
|
||||
const hasFieldErrors = Object.values(fieldErrors).some(Boolean);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = buildManagedNeSaveBody(form, {
|
||||
|
|
@ -84,6 +89,17 @@ export function ManagedNeFormDialog({
|
|||
},
|
||||
});
|
||||
|
||||
const editingId = editing?.id ?? "";
|
||||
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]);
|
||||
|
||||
return (
|
||||
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
|
||||
<Modal.Header>
|
||||
|
|
@ -113,12 +129,13 @@ export function ManagedNeFormDialog({
|
|||
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";
|
||||
const suggested = deviceTypeForVendor(vendor);
|
||||
// Follow the vendor while the type is a placeholder or still the old vendor's default.
|
||||
const followVendor = !dt || dt === deviceTypeForVendor(prev.vendor)
|
||||
|| ((dt === "generic" || dt === "other" || dt === "linux") && suggested !== "generic");
|
||||
if (followVendor) {
|
||||
next.device_type = suggested;
|
||||
if (!allowsOpenExecPolicy(suggested)) next.exec_policy = "readonly";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
|
@ -175,25 +192,39 @@ export function ManagedNeFormDialog({
|
|||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
isInvalid={submitted && Boolean(fieldErrors.ip)}
|
||||
value={form.ip_address}
|
||||
onChange={(ip_address) => setForm({ ...form, ip_address })}
|
||||
>
|
||||
<Label>{t("managedNe.col.ip")}</Label>
|
||||
<Input />
|
||||
<Input placeholder="192.0.2.10 / pe-01.example.net" />
|
||||
{submitted && fieldErrors.ip ? <span className="form-field-hint error-text">{fieldErrors.ip}</span> : null}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={String(form.port)}
|
||||
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
|
||||
isRequired
|
||||
inputMode="numeric"
|
||||
isInvalid={submitted && Boolean(fieldErrors.port)}
|
||||
value={portDraft}
|
||||
onChange={(value) => {
|
||||
setPortDraft(value);
|
||||
if (/^\d+$/.test(value.trim())) setForm((prev) => ({ ...prev, port: Number(value.trim()) }));
|
||||
}}
|
||||
>
|
||||
<Label>{t("managedNe.col.port")}</Label>
|
||||
<Input />
|
||||
{submitted && fieldErrors.port ? <span className="form-field-hint error-text">{fieldErrors.port}</span> : null}
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.protocol")}
|
||||
value={form.protocol}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const protocol = e.target.value;
|
||||
// Keep the well-known port in step with the protocol unless it was customised.
|
||||
const swapPort = protocol === "telnet" && portNum === 22 ? 23 : protocol === "ssh" && portNum === 23 ? 22 : 0;
|
||||
setForm((prev) => ({ ...prev, protocol, ...(swapPort ? { port: swapPort } : {}) }));
|
||||
if (swapPort) setPortDraft(String(swapPort));
|
||||
}}
|
||||
>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="telnet">telnet</option>
|
||||
|
|
@ -201,11 +232,13 @@ export function ManagedNeFormDialog({
|
|||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
isInvalid={submitted && Boolean(fieldErrors.username)}
|
||||
value={form.username}
|
||||
onChange={(username) => setForm({ ...form, username })}
|
||||
>
|
||||
<Label>{t("managedNe.col.user")}</Label>
|
||||
<Input />
|
||||
{submitted && fieldErrors.username ? <span className="form-field-hint error-text">{fieldErrors.username}</span> : null}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
|
|
@ -279,7 +312,7 @@ export function ManagedNeFormDialog({
|
|||
{saveMutation.isError ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
|
||||
<Alert.Description>{managedNeErrorText(saveMutation.error, t)}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
|
@ -291,7 +324,11 @@ export function ManagedNeFormDialog({
|
|||
<Button
|
||||
variant="primary"
|
||||
isDisabled={saveMutation.isPending}
|
||||
onPress={() => saveMutation.mutate()}
|
||||
onPress={() => {
|
||||
setSubmitted(true);
|
||||
if (hasFieldErrors) return;
|
||||
saveMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</Button>
|
||||
|
|
|
|||
19
web/src/features/managed-ne/lib/errors.ts
Normal file
19
web/src/features/managed-ne/lib/errors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { formatErr } from "../../../services/api";
|
||||
|
||||
type Translate = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
/** Map API detail codes such as `ip_address_exists` or `hop_password_required: a,b` to localized text. */
|
||||
export function managedNeErrorText(err: unknown, t: Translate): string {
|
||||
const raw = formatErr(err);
|
||||
const matched = /^([a-z_]+)(?::\s*([\s\S]*))?$/.exec(raw.trim());
|
||||
if (!matched) return raw;
|
||||
const key = `managedNe.errors.${matched[1]}`;
|
||||
const template = t(key);
|
||||
if (template === key) return raw;
|
||||
const detail = (matched[2] || "").trim();
|
||||
if (detail) return template.replace(/\{\{detail\}\}/g, detail);
|
||||
// No detail from the API: drop the "(…{{detail}})" or ": {{detail}}" part of the message.
|
||||
return template
|
||||
.replace(/\s*[((][^(())]*\{\{detail\}\}[))]/g, "")
|
||||
.replace(/\s*[::]\s*\{\{detail\}\}/g, "");
|
||||
}
|
||||
|
|
@ -30,6 +30,26 @@ export type ManagedNeFormState = {
|
|||
exec_policy: "readonly" | "linux_shell" | "unrestricted";
|
||||
};
|
||||
|
||||
const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const HOSTNAME_RE = /^(?=.{1,253}$)[A-Za-z0-9_][A-Za-z0-9_-]{0,62}(\.[A-Za-z0-9_][A-Za-z0-9_-]{0,62})*\.?$/;
|
||||
|
||||
/** IPv4 / IPv6 literal or DNS hostname; mirrors the API's `invalid_ip_address` check. */
|
||||
export function isValidMgmtAddress(value: string): boolean {
|
||||
const text = value.trim();
|
||||
if (!text) return false;
|
||||
if (IPV4_RE.test(text)) return true;
|
||||
if (/^[0-9.]+$/.test(text)) return false;
|
||||
if (text.includes(":")) {
|
||||
try {
|
||||
new URL(`http://[${text}]/`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return HOSTNAME_RE.test(text);
|
||||
}
|
||||
|
||||
export function deviceTypeForVendor(vendor: string): string {
|
||||
if (vendor === "ZTE") return "zte_zxros";
|
||||
if (vendor === "Huawei") return "huawei";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue