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:
oliver 2026-10-12 03:17:51 +08:00
parent 3b45d6abe3
commit 0d894efe5e
196 changed files with 26947 additions and 28091 deletions

File diff suppressed because it is too large Load diff

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

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,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;
}

View 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 };

View 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);
}