mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 17:13:21 +08:00
Refactor web UI: feature-first structure, tokenized styles, modern blue theme.
Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
(2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.
Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
without tree indent, soft selected state instead of left accent bar.
Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b45d6abe3
commit
0d894efe5e
196 changed files with 26947 additions and 28091 deletions
1084
web/src/features/managed-ne/NePage.tsx
Normal file
1084
web/src/features/managed-ne/NePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
250
web/src/features/managed-ne/components/HopProxyFields.tsx
Normal file
250
web/src/features/managed-ne/components/HopProxyFields.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { Input, Label, TextField } from "@heroui/react";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { FieldSelect } from "../../../components/ui/FieldSelect";
|
||||
import {
|
||||
HOP_VENDORS,
|
||||
defaultHopTemplate,
|
||||
expandBastionHopFields,
|
||||
isAutoHopTemplate,
|
||||
isBastionHopVendor,
|
||||
isLinuxHopVendor,
|
||||
patchHopVendorChange,
|
||||
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,
|
||||
});
|
||||
|
||||
function applyHopTemplate(
|
||||
prev: HopProxyFieldsState,
|
||||
protocol: string,
|
||||
vrf: string,
|
||||
force = false,
|
||||
): Partial<HopProxyFieldsState> {
|
||||
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) };
|
||||
}
|
||||
|
||||
function hopHintKey(vendor: string): string {
|
||||
const v = String(vendor || "").toLowerCase();
|
||||
if (v === "bastion") return "managedNe.hop.bastionHint";
|
||||
if (v === "linux") return "managedNe.hop.linuxHint";
|
||||
if (v === "huawei") return "managedNe.hop.huaweiHint";
|
||||
if (v === "cisco") return "managedNe.hop.ciscoHint";
|
||||
return "managedNe.hop.zteHint";
|
||||
}
|
||||
|
||||
function templateHintKey(vendor: string): string {
|
||||
const v = String(vendor || "").toLowerCase();
|
||||
if (v === "bastion") return "managedNe.hop.templateHintBastion";
|
||||
if (v === "huawei") return "managedNe.hop.templateHintHuawei";
|
||||
if (v === "cisco") return "managedNe.hop.templateHintCisco";
|
||||
return "managedNe.hop.templateHint";
|
||||
}
|
||||
|
||||
function vrfLabelKey(vendor: string): string {
|
||||
const v = String(vendor || "").toLowerCase();
|
||||
if (v === "huawei") return "managedNe.hop.vpnInstance";
|
||||
if (v === "cisco") return "managedNe.hop.vrfCisco";
|
||||
return "managedNe.hop.vrf";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: HopProxyFieldsState;
|
||||
onChange: (patch: Partial<HopProxyFieldsState>) => void;
|
||||
hopPasswordRequired?: boolean;
|
||||
hopPasswordOptional?: boolean;
|
||||
};
|
||||
|
||||
export function HopProxyFields({
|
||||
value,
|
||||
onChange,
|
||||
hopPasswordRequired = true,
|
||||
hopPasswordOptional = false,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const linux = isLinuxHopVendor(value.hop_vendor);
|
||||
const bastion = isBastionHopVendor(value.hop_vendor);
|
||||
const huawei = value.hop_vendor === "huawei";
|
||||
const cliHop = !linux && !bastion;
|
||||
|
||||
const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch);
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<FieldSelect
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
label={t("managedNe.hop.type")}
|
||||
required
|
||||
value={value.hop_vendor}
|
||||
hint={t(hopHintKey(value.hop_vendor))}
|
||||
onChange={(e) => {
|
||||
const hop_vendor = e.target.value as HopVendor;
|
||||
set(patchHopVendorChange(hop_vendor, value));
|
||||
}}
|
||||
>
|
||||
{HOP_VENDORS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`managedNe.hop.vendor.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={value.hop_host}
|
||||
onChange={(hop_host) => set({ hop_host })}
|
||||
onBlur={() => {
|
||||
if (!bastion) return;
|
||||
const raw = String(value.hop_host || "").trim();
|
||||
if (!raw.includes("@")) return;
|
||||
const parsed = expandBastionHopFields(raw, value.hop_username);
|
||||
if (!parsed.hop_host || parsed.hop_host === raw) return;
|
||||
set({
|
||||
hop_host: parsed.hop_host,
|
||||
hop_username: parsed.hop_username || value.hop_username,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Label>{t("managedNe.hop.host")}</Label>
|
||||
<Input placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined} />
|
||||
{bastion ? <span className="form-field-hint">{t("managedNe.hop.hostHintBastion")}</span> : null}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={String(value.hop_port)}
|
||||
onChange={(hop_port) => set({ hop_port: Number(hop_port) || 22 })}
|
||||
>
|
||||
<Label>{t("managedNe.hop.port")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
{bastion ? (
|
||||
<FieldSelect
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
label={t("managedNe.hop.targetAuthMode")}
|
||||
value={value.hop_target_auth_mode}
|
||||
hint={t("managedNe.hop.targetAuthHint")}
|
||||
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
|
||||
>
|
||||
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
|
||||
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
|
||||
</FieldSelect>
|
||||
) : null}
|
||||
{cliHop ? (
|
||||
<FieldSelect
|
||||
label={t("managedNe.hop.protocol")}
|
||||
value={value.hop_protocol}
|
||||
onChange={(e) => {
|
||||
const hop_protocol = e.target.value;
|
||||
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
|
||||
}}
|
||||
>
|
||||
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
|
||||
<option value="telnet">telnet</option>
|
||||
</FieldSelect>
|
||||
) : null}
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={value.hop_username}
|
||||
onChange={(hop_username) => set({ hop_username })}
|
||||
>
|
||||
<Label>{t("managedNe.hop.username")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
isRequired={hopPasswordRequired}
|
||||
value={value.hop_password}
|
||||
onChange={(hop_password) => set({ hop_password })}
|
||||
>
|
||||
<Label>
|
||||
{t("managedNe.hop.password")}
|
||||
{hopPasswordOptional ? (
|
||||
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
|
||||
) : null}
|
||||
</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
{bastion ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
value={value.hop_command_template}
|
||||
onChange={(hop_command_template) => set({ hop_command_template })}
|
||||
>
|
||||
<Label>{t("managedNe.hop.usernameTemplate")}</Label>
|
||||
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
|
||||
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
|
||||
</TextField>
|
||||
) : null}
|
||||
{cliHop ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={value.hop_vrf}
|
||||
onChange={(hop_vrf) => {
|
||||
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
|
||||
}}
|
||||
>
|
||||
<Label>{t(vrfLabelKey(value.hop_vendor))}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
{huawei ? (
|
||||
<FieldSelect
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
label={t("managedNe.hop.enterSystemView")}
|
||||
value={value.hop_enter_system_view ? "yes" : "no"}
|
||||
hint={t("managedNe.hop.enterSystemViewHint")}
|
||||
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
|
||||
>
|
||||
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
|
||||
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
|
||||
</FieldSelect>
|
||||
) : null}
|
||||
<TextField
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
value={value.hop_command_template}
|
||||
onChange={(hop_command_template) => set({ hop_command_template })}
|
||||
>
|
||||
<Label>{t("managedNe.hop.commandTemplate")}</Label>
|
||||
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
|
||||
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
|
||||
</TextField>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Alert, Button, Chip, Modal } from "@heroui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { connectTestManagedNe } from "../../../services/api";
|
||||
import { AppModalShell } from "../../../components/ui/AppModalShell";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { formatSystemTime } from "../../../utils/time";
|
||||
import type { ManagedNeItem } from "../../../types";
|
||||
import { connectPillLevel } from "../lib/connectStatus";
|
||||
|
||||
export type ManagedNeConnectDetailDialogProps = {
|
||||
row: ManagedNeItem | null;
|
||||
onClose: () => void;
|
||||
/** Called after a retest is submitted so parent can refresh/poll. */
|
||||
onRetestSubmitted?: (rowId: string) => void;
|
||||
};
|
||||
|
||||
function chipColor(level: string): "success" | "danger" | "warning" | "default" {
|
||||
if (level === "up" || level === "pass") return "success";
|
||||
if (level === "down" || level === "fail") return "danger";
|
||||
if (level === "testing") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function ManagedNeConnectDetailDialog({
|
||||
row,
|
||||
onClose,
|
||||
onRetestSubmitted,
|
||||
}: ManagedNeConnectDetailDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: connectTestManagedNe,
|
||||
onSuccess: () => {
|
||||
if (row) onRetestSubmitted?.(row.id);
|
||||
},
|
||||
});
|
||||
|
||||
const open = Boolean(row);
|
||||
const level = row ? connectPillLevel(row.connect_status) : "unknown";
|
||||
|
||||
return (
|
||||
<AppModalShell open={open} onClose={onClose} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("managedNe.connectDetailTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{row ? (
|
||||
<>
|
||||
<p className="text-sm text-muted">
|
||||
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
|
||||
{row.connect_tested_at
|
||||
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
|
||||
: ""}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Chip size="sm" color={chipColor(level)} variant="soft">
|
||||
<Chip.Label>{row.connect_status}</Chip.Label>
|
||||
</Chip>
|
||||
{row.connect_message ? (
|
||||
<span className="connect-detail-summary text-sm">— {row.connect_message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="connect-log">
|
||||
{row.connect_detail?.trim() ||
|
||||
row.connect_message?.trim() ||
|
||||
t("managedNe.connectDetailEmpty")}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
{connectMutation.isError ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{String(connectMutation.error)}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={!row || connectMutation.isPending}
|
||||
onPress={() => {
|
||||
if (row) connectMutation.mutate([row.id]);
|
||||
}}
|
||||
>
|
||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onPress={onClose}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
);
|
||||
}
|
||||
301
web/src/features/managed-ne/components/ManagedNeFormDialog.tsx
Normal file
301
web/src/features/managed-ne/components/ManagedNeFormDialog.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../../services/api";
|
||||
import { HopProxyFields } from "./HopProxyFields";
|
||||
import { AppModalShell } from "../../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { patchHopVendorChange } from "../lib/hopProxy";
|
||||
import type { ManagedNeItem } from "../../../types";
|
||||
import {
|
||||
applyHopTemplate,
|
||||
buildManagedNeSaveBody,
|
||||
emptyManagedNeForm,
|
||||
formFromManagedNe,
|
||||
allowsOpenExecPolicy,
|
||||
type ManagedNeFormState,
|
||||
} from "../lib/formState";
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
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"),
|
||||
execPolicyEnabled: Boolean(metaQuery.data?.exec_policy_enabled),
|
||||
});
|
||||
if (editing) {
|
||||
return updateManagedNe(editing.id, body);
|
||||
}
|
||||
return createManagedNe(body);
|
||||
},
|
||||
onSuccess: (item) => {
|
||||
onSaved(item);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>
|
||||
{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
|
||||
</Modal.Heading>
|
||||
<Modal.CloseTrigger isDisabled={saveMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">{t("managedNe.form.requiredHint")}</p>
|
||||
<div className="form-grid">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={form.name}
|
||||
onChange={(name) => setForm({ ...form, name })}
|
||||
>
|
||||
<Label>{t("managedNe.col.name")}</Label>
|
||||
<Input />
|
||||
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.vendor")}
|
||||
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>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.deviceType")}
|
||||
required
|
||||
value={form.device_type}
|
||||
onChange={(e) => {
|
||||
const device_type = e.target.value;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
device_type,
|
||||
exec_policy: allowsOpenExecPolicy(device_type) ? prev.exec_policy : "readonly",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{deviceTypes.map((dt) => (
|
||||
<option key={dt} value={dt}>
|
||||
{dt}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
{execPolicyEnabled ? (
|
||||
<>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.execPolicy")}
|
||||
value={form.exec_policy}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
exec_policy: e.target.value as ManagedNeFormState["exec_policy"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="readonly">{t("managedNe.execPolicy.readonly")}</option>
|
||||
{allowsOpenExecPolicy(form.device_type) ? (
|
||||
<>
|
||||
<option value="linux_shell">{t("managedNe.execPolicy.linuxShell")}</option>
|
||||
<option value="unrestricted">{t("managedNe.execPolicy.unrestricted")}</option>
|
||||
</>
|
||||
) : null}
|
||||
</FieldSelect>
|
||||
<p className="form-field-hint form-grid__full">{t("managedNe.execPolicy.hint")}</p>
|
||||
</>
|
||||
) : null}
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={form.ip_address}
|
||||
onChange={(ip_address) => setForm({ ...form, ip_address })}
|
||||
>
|
||||
<Label>{t("managedNe.col.ip")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={String(form.port)}
|
||||
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
|
||||
>
|
||||
<Label>{t("managedNe.col.port")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.protocol")}
|
||||
value={form.protocol}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
|
||||
>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="telnet">telnet</option>
|
||||
</FieldSelect>
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={form.username}
|
||||
onChange={(username) => setForm({ ...form, username })}
|
||||
>
|
||||
<Label>{t("managedNe.col.user")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(password) => setForm({ ...form, password })}
|
||||
>
|
||||
<Label>
|
||||
{t("managedNe.col.password")}
|
||||
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
|
||||
</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField fullWidth value={form.tags} onChange={(tags) => setForm({ ...form, tags })}>
|
||||
<Label>{t("managedNe.col.tags")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
value={form.remark}
|
||||
onChange={(remark) => setForm({ ...form, remark })}
|
||||
>
|
||||
<Label>{t("managedNe.col.remark")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
<fieldset className="form-fieldset form-grid__full">
|
||||
<legend>{t("managedNe.hop.sectionTitle")}</legend>
|
||||
<Checkbox
|
||||
isSelected={form.hop_enabled}
|
||||
onChange={(hop_enabled) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hop_enabled,
|
||||
...(hop_enabled
|
||||
? {
|
||||
...patchHopVendorChange(prev.hop_vendor, prev),
|
||||
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
|
||||
</Checkbox>
|
||||
{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>
|
||||
{saveMutation.isError ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" isDisabled={saveMutation.isPending} onPress={onClose}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={saveMutation.isPending}
|
||||
onPress={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
);
|
||||
}
|
||||
14
web/src/features/managed-ne/lib/connectStatus.ts
Normal file
14
web/src/features/managed-ne/lib/connectStatus.ts
Normal 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";
|
||||
}
|
||||
206
web/src/features/managed-ne/lib/formState.ts
Normal file
206
web/src/features/managed-ne/lib/formState.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
defaultHopTemplate,
|
||||
isAutoHopTemplate,
|
||||
type HopVendor,
|
||||
} from "./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;
|
||||
exec_policy: "readonly" | "linux_shell" | "unrestricted";
|
||||
};
|
||||
|
||||
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 isLinuxDeviceType(deviceType: string | undefined | null): boolean {
|
||||
const low = String(deviceType || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return low === "linux" || low === "linux_ssh" || low === "linux_telnet" || low.startsWith("linux_");
|
||||
}
|
||||
|
||||
export function isMikrotikDeviceType(deviceType: string | undefined | null): boolean {
|
||||
const low = String(deviceType || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return (
|
||||
low === "mikrotik_routeros" ||
|
||||
low === "mikrotik_switchos" ||
|
||||
low.startsWith("mikrotik_")
|
||||
);
|
||||
}
|
||||
|
||||
/** Linux + MikroTik may use linux_shell / unrestricted (multiline scripts). */
|
||||
export function allowsOpenExecPolicy(deviceType: string | undefined | null): boolean {
|
||||
return isLinuxDeviceType(deviceType) || isMikrotikDeviceType(deviceType);
|
||||
}
|
||||
|
||||
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,
|
||||
exec_policy: "readonly",
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
exec_policy:
|
||||
row.exec_policy === "linux_shell" || row.exec_policy === "unrestricted"
|
||||
? row.exec_policy
|
||||
: "readonly",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
/** When false, omit exec_policy so lab-open cannot be persisted accidentally. */
|
||||
execPolicyEnabled?: boolean;
|
||||
},
|
||||
): 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 (opts.execPolicyEnabled) {
|
||||
body.exec_policy = allowsOpenExecPolicy(form.device_type) ? form.exec_policy : "readonly";
|
||||
}
|
||||
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;
|
||||
}
|
||||
165
web/src/features/managed-ne/lib/hopProxy.ts
Normal file
165
web/src/features/managed-ne/lib/hopProxy.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/** Jump-host (hop) templates per vendor. */
|
||||
|
||||
import { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate } from "./zteHop";
|
||||
|
||||
export type HopVendor = "zte" | "huawei" | "cisco" | "linux" | "bastion";
|
||||
|
||||
export type HopTargetAuthMode = "bastion_managed" | "manual";
|
||||
|
||||
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux", "bastion"];
|
||||
|
||||
export function bastionHopTemplate(): string {
|
||||
return "{hop_user}@{target_user}@{target_ip}";
|
||||
}
|
||||
|
||||
export const LEGACY_BASTION_HOP_TEMPLATE = "{hop_user}@{target_user}@{target_ip}@{hop_host}";
|
||||
|
||||
export function isBastionHopVendor(vendor: string): boolean {
|
||||
return String(vendor || "").toLowerCase() === "bastion";
|
||||
}
|
||||
|
||||
export function isLinuxHopVendor(vendor: string): boolean {
|
||||
return String(vendor || "").toLowerCase() === "linux";
|
||||
}
|
||||
|
||||
export function isCliHopVendor(vendor: string): boolean {
|
||||
const v = String(vendor || "").toLowerCase();
|
||||
return v === "zte" || v === "huawei" || v === "cisco";
|
||||
}
|
||||
|
||||
/** Strip ssh:// / trailing slash / :port from bastion host (IP or FQDN). */
|
||||
export function normalizeHopHost(value: string): string {
|
||||
let host = String(value || "").trim();
|
||||
if (!host) return "";
|
||||
host = host.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
|
||||
if (host.includes("://")) host = host.split("://", 2)[1] || host;
|
||||
host = host.trim().replace(/\/+$/, "");
|
||||
if (host.startsWith("[") && host.includes("]")) {
|
||||
const inside = host.slice(1, host.indexOf("]"));
|
||||
const rest = host.slice(host.indexOf("]") + 1);
|
||||
if (!rest || rest.startsWith(":")) return inside.trim();
|
||||
return host;
|
||||
}
|
||||
if ((host.match(/:/g) || []).length === 1) {
|
||||
const [left, right] = host.split(":");
|
||||
if (/^\d+$/.test(right || "") && left && !left.includes("@")) return left.trim();
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OpenSSH-style bastion destination.
|
||||
* Example: bastion-user@target-user@198.51.100.20@ssh-bastion.example.com
|
||||
*/
|
||||
export function parseBastionSshDestination(value: string): {
|
||||
hop_host: string;
|
||||
hop_username: string;
|
||||
target_user: string;
|
||||
target_ip: string;
|
||||
ssh_username: string;
|
||||
} {
|
||||
let raw = String(value || "").trim().replace(/^["']|["']$/g, "");
|
||||
raw = raw.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
|
||||
if (!raw) {
|
||||
return { hop_host: "", hop_username: "", target_user: "", target_ip: "", ssh_username: "" };
|
||||
}
|
||||
if (!raw.includes("@")) {
|
||||
return {
|
||||
hop_host: normalizeHopHost(raw),
|
||||
hop_username: "",
|
||||
target_user: "",
|
||||
target_ip: "",
|
||||
ssh_username: "",
|
||||
};
|
||||
}
|
||||
const at = raw.lastIndexOf("@");
|
||||
const userPart = raw.slice(0, at).trim();
|
||||
const hopHost = normalizeHopHost(raw.slice(at + 1));
|
||||
const parts = userPart.split("@").filter(Boolean);
|
||||
return {
|
||||
hop_host: hopHost,
|
||||
hop_username: parts[0] || "",
|
||||
target_user: parts[1] || "",
|
||||
target_ip: parts[2] || "",
|
||||
ssh_username: userPart,
|
||||
};
|
||||
}
|
||||
|
||||
/** If hop_host is a pasted user@…@bastion string, split into host + hop username. */
|
||||
export function expandBastionHopFields(
|
||||
hopHost: string,
|
||||
hopUsername = "",
|
||||
): {
|
||||
hop_host: string;
|
||||
hop_username: string;
|
||||
target_user: string;
|
||||
target_ip: string;
|
||||
} {
|
||||
const curUser = String(hopUsername || "").trim();
|
||||
const raw = String(hopHost || "").trim();
|
||||
if (!raw.includes("@")) {
|
||||
return { hop_host: normalizeHopHost(raw), hop_username: curUser, target_user: "", target_ip: "" };
|
||||
}
|
||||
const parsed = parseBastionSshDestination(raw);
|
||||
return {
|
||||
hop_host: parsed.hop_host,
|
||||
hop_username: parsed.hop_username || curUser,
|
||||
target_user: parsed.target_user,
|
||||
target_ip: parsed.target_ip,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultHopTemplate(vendor: string, protocol: string, vrf: string): string {
|
||||
const v = String(vendor || "zte").toLowerCase();
|
||||
if (v === "huawei") return huaweiHopTemplate(protocol, vrf);
|
||||
if (v === "cisco") return ciscoHopTemplate(protocol, vrf);
|
||||
if (v === "linux") return "";
|
||||
if (v === "bastion") return bastionHopTemplate();
|
||||
return zteHopTemplate(protocol, vrf);
|
||||
}
|
||||
|
||||
export function patchHopVendorChange(
|
||||
vendor: HopVendor,
|
||||
prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string; hop_vendor?: string },
|
||||
): {
|
||||
hop_vendor: HopVendor;
|
||||
hop_protocol: string;
|
||||
hop_vrf: string;
|
||||
hop_command_template: string;
|
||||
hop_port?: number;
|
||||
hop_target_auth_mode?: HopTargetAuthMode;
|
||||
hop_enter_system_view?: boolean;
|
||||
} {
|
||||
if (vendor === "linux") {
|
||||
return {
|
||||
hop_vendor: "linux",
|
||||
hop_protocol: "ssh",
|
||||
hop_vrf: "",
|
||||
hop_command_template: "",
|
||||
hop_enter_system_view: false,
|
||||
};
|
||||
}
|
||||
if (vendor === "bastion") {
|
||||
return {
|
||||
hop_vendor: "bastion",
|
||||
hop_protocol: "ssh",
|
||||
hop_port: 22,
|
||||
hop_vrf: "",
|
||||
hop_command_template: bastionHopTemplate(),
|
||||
hop_target_auth_mode: "bastion_managed" as HopTargetAuthMode,
|
||||
hop_enter_system_view: false,
|
||||
};
|
||||
}
|
||||
const protocol = prev.hop_protocol || "ssh";
|
||||
const vrf = prev.hop_vrf || "";
|
||||
return {
|
||||
hop_vendor: vendor,
|
||||
hop_protocol: protocol,
|
||||
hop_vrf: vrf,
|
||||
hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
|
||||
// Explicit Huawei option only; reset when leaving/entering other CLI hop vendors.
|
||||
hop_enter_system_view: false,
|
||||
};
|
||||
}
|
||||
|
||||
export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate };
|
||||
52
web/src/features/managed-ne/lib/zteHop.ts
Normal file
52
web/src/features/managed-ne/lib/zteHop.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/** Vendor CLI jump command templates (placeholders). */
|
||||
|
||||
const LEGACY_HOP_TEMPLATES = new Set([
|
||||
"ssh {target_user}@{target_ip}",
|
||||
"ssh {target_ip}",
|
||||
"telnet {target_ip}",
|
||||
]);
|
||||
|
||||
export function zteHopTemplate(protocol: string, vrf: string): string {
|
||||
const cmd = String(protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh";
|
||||
const v = String(vrf || "").trim();
|
||||
return v ? `${cmd} {target_ip} vrf {vrf}` : `${cmd} {target_ip}`;
|
||||
}
|
||||
|
||||
/** Cisco: ssh -vrf VRF IP; telnet IP [/vrf VRF]. */
|
||||
export function ciscoHopTemplate(protocol: string, vrf: string): string {
|
||||
const v = String(vrf || "").trim();
|
||||
if (String(protocol || "ssh").toLowerCase() === "telnet") {
|
||||
return v ? `telnet {target_ip} /vrf {vrf}` : `telnet {target_ip}`;
|
||||
}
|
||||
return v ? `ssh -vrf {vrf} {target_ip}` : `ssh {target_ip}`;
|
||||
}
|
||||
|
||||
/** Huawei: telnet [vpn-instance VRF] IP; SSH uses stelnet. */
|
||||
export function huaweiHopTemplate(protocol: string, vrf: string): string {
|
||||
const v = String(vrf || "").trim();
|
||||
if (String(protocol || "ssh").toLowerCase() === "telnet") {
|
||||
return v ? `telnet vpn-instance {vrf} {target_ip}` : `telnet {target_ip}`;
|
||||
}
|
||||
return v ? `stelnet {target_ip} -vpn-instance {vrf}` : `stelnet {target_ip}`;
|
||||
}
|
||||
|
||||
export function isAutoHopTemplate(
|
||||
template: string,
|
||||
vendor: string,
|
||||
protocol: string,
|
||||
vrf: string,
|
||||
): boolean {
|
||||
const t = String(template || "").trim();
|
||||
if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true;
|
||||
const v = String(vendor || "zte").toLowerCase();
|
||||
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
|
||||
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
|
||||
if (v === "linux") return t === "";
|
||||
if (v === "bastion") {
|
||||
return (
|
||||
t === "{hop_user}@{target_user}@{target_ip}"
|
||||
|| t === "{hop_user}@{target_user}@{target_ip}@{hop_host}"
|
||||
);
|
||||
}
|
||||
return t === zteHopTemplate(protocol, vrf);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue