mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 09:43:58 +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
|
|
@ -1,7 +0,0 @@
|
|||
export function AppsGridIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden>
|
||||
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */
|
||||
export function HeaderMenu() {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const next = locale === "zh" ? "en" : "zh";
|
||||
const label = locale === "zh" ? "ZH" : "EN";
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale"
|
||||
aria-label={t("layout.switchLanguage")}
|
||||
onPress={() => setLocale(next)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
import { Input, Label, TextField } from "@heroui/react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { FieldSelect } from "./ui/FieldSelect";
|
||||
import {
|
||||
HOP_VENDORS,
|
||||
defaultHopTemplate,
|
||||
expandBastionHopFields,
|
||||
isAutoHopTemplate,
|
||||
isBastionHopVendor,
|
||||
isLinuxHopVendor,
|
||||
patchHopVendorChange,
|
||||
type HopTargetAuthMode,
|
||||
type HopVendor,
|
||||
} from "../utils/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>
|
||||
);
|
||||
}
|
||||
83
web/src/components/NmChips.tsx
Normal file
83
web/src/components/NmChips.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Chip } from "@heroui/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type NmChipColor = "success" | "danger" | "warning" | "default" | "accent";
|
||||
|
||||
export function sourceChipColor(source: string | null | undefined): NmChipColor {
|
||||
const s = String(source || "").trim().toLowerCase();
|
||||
if (s === "ume") return "accent";
|
||||
if (s === "import") return "warning";
|
||||
if (s === "managed" || s === "webcrt") return "default";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Connection / health style statuses. */
|
||||
export function connectChipColor(status: string | null | undefined): NmChipColor {
|
||||
const s = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!s || s === "-" || s === "—" || s === "unknown") return "default";
|
||||
if (s.includes("fail") || s.includes("down") || s.includes("error") || s.includes("critical")) {
|
||||
return "danger";
|
||||
}
|
||||
if (s.includes("ok") || s.includes("up") || s.includes("connected") || s.includes("success") || s.includes("pass")) {
|
||||
return "success";
|
||||
}
|
||||
if (s.includes("warn") || s.includes("degrad") || s.includes("test") || s.includes("major") || s.includes("minor")) {
|
||||
return "warning";
|
||||
}
|
||||
return "accent";
|
||||
}
|
||||
|
||||
/** Job / cycle / task lifecycle statuses. */
|
||||
export function jobChipColor(status: string | null | undefined): NmChipColor {
|
||||
const s = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!s) return "default";
|
||||
if (s === "running" || s === "pending" || s === "collect") return "success";
|
||||
if (s === "paused" || s === "draft" || s === "skipped" || s === "partial" || s === "cancelled" || s === "queued") {
|
||||
return "warning";
|
||||
}
|
||||
if (s === "failed" || s === "fail" || s === "error" || s === "stopped") return "danger";
|
||||
if (s === "success" || s === "completed" || s === "ok" || s === "done") return "success";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Fabric / LLDP edge status (active / missing / stale). */
|
||||
export function edgeChipColor(status: string | null | undefined): NmChipColor {
|
||||
const s = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (s === "active" || s === "ok" || s === "up") return "success";
|
||||
if (s === "missing" || s === "stale" || s === "down") return "warning";
|
||||
if (s.includes("fail") || s.includes("error")) return "danger";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Alarm perceived severity. */
|
||||
export function severityChipColor(sev: string | null | undefined): NmChipColor {
|
||||
const s = String(sev || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (s.includes("critical")) return "danger";
|
||||
if (s.includes("major")) return "warning";
|
||||
if (s.includes("minor") || s.includes("warning") || s.includes("warn")) return "warning";
|
||||
if (s.includes("info") || s.includes("indeterminate")) return "accent";
|
||||
return "default";
|
||||
}
|
||||
|
||||
type NmStatusChipProps = {
|
||||
color: NmChipColor;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Soft status/source chip used across Network Management lists. */
|
||||
export function NmStatusChip({ color, children, className }: NmStatusChipProps) {
|
||||
return (
|
||||
<Chip size="sm" variant="soft" color={color} className={className}>
|
||||
<Chip.Label>{children}</Chip.Label>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,520 +0,0 @@
|
|||
import { Button, Checkbox, Input, Label, TextField } from "@heroui/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
apiDelete,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
fetchCliMeta,
|
||||
fetchCliProfiles,
|
||||
fetchManagedNeMeta,
|
||||
fetchUmeCliOverride,
|
||||
fetchUmeNe,
|
||||
postUmeConnectTest,
|
||||
} from "../services/api";
|
||||
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
|
||||
import { HelpHint } from "./HelpHint";
|
||||
import { FieldSelect } from "./ui/FieldSelect";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CliConnectProfileItem, UmeCliOverrideItem, UmeNeItem } from "../types";
|
||||
import { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange } from "../utils/hopProxy";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function formatUmeNePickLabel(row: UmeNeItem): string {
|
||||
const name = String(row.user_label || row.ne_name || row.host_name || "").trim();
|
||||
const ip = String(row.ip_address || "").trim();
|
||||
const id = String(row.ne_id || "").trim();
|
||||
const head = [name, ip].filter(Boolean).join(" · ");
|
||||
return head ? `${head} · ${id}` : id;
|
||||
}
|
||||
|
||||
type ProfileForm = {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
device_type_default: string;
|
||||
vendor_default: string;
|
||||
is_default: boolean;
|
||||
hop_enabled: boolean;
|
||||
hop: HopProxyFieldsState;
|
||||
};
|
||||
|
||||
function applyHopTemplate(hop: HopProxyFieldsState, protocol: string, vrf: string, force = false): Partial<HopProxyFieldsState> {
|
||||
if (!force && !isAutoHopTemplate(hop.hop_command_template, hop.hop_vendor, hop.hop_protocol, hop.hop_vrf)) {
|
||||
return {};
|
||||
}
|
||||
return { hop_command_template: defaultHopTemplate(hop.hop_vendor, protocol, vrf) };
|
||||
}
|
||||
|
||||
const emptyForm = (): ProfileForm => ({
|
||||
id: "",
|
||||
name: "default",
|
||||
username: "",
|
||||
password: "",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
device_type_default: "zte_zxros",
|
||||
vendor_default: "ZTE",
|
||||
is_default: true,
|
||||
hop_enabled: false,
|
||||
hop: emptyHopProxyFields(),
|
||||
});
|
||||
|
||||
function profileToForm(row: CliConnectProfileItem): ProfileForm {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
username: row.username,
|
||||
password: "",
|
||||
port: row.port,
|
||||
protocol: row.protocol,
|
||||
device_type_default: row.device_type_default,
|
||||
vendor_default: row.vendor_default,
|
||||
is_default: row.is_default,
|
||||
hop_enabled: row.hop_enabled,
|
||||
hop: {
|
||||
hop_vendor: (row.hop_vendor || "bastion") as HopProxyFieldsState["hop_vendor"],
|
||||
hop_host: row.hop_host ?? "",
|
||||
hop_port: row.hop_port ?? 22,
|
||||
hop_protocol: row.hop_protocol ?? "ssh",
|
||||
hop_username: row.hop_username ?? "",
|
||||
hop_password: "",
|
||||
hop_command_template: row.hop_command_template ?? "",
|
||||
hop_vrf: row.hop_vrf ?? "",
|
||||
hop_target_auth_mode: (row.hop_target_auth_mode || "bastion_managed") as HopProxyFieldsState["hop_target_auth_mode"],
|
||||
hop_enter_system_view: Boolean(row.hop_enter_system_view),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabled?: boolean; embedded?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<ProfileForm>(emptyForm);
|
||||
const [sampleUmeNeId, setSampleUmeNeId] = useState("");
|
||||
const [sampleFilter, setSampleFilter] = useState("");
|
||||
const [sampleOpen, setSampleOpen] = useState(false);
|
||||
const [debouncedFilter, setDebouncedFilter] = useState("");
|
||||
const [testPending, setTestPending] = useState(false);
|
||||
const sampleSuggestRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const testNeId = sampleUmeNeId.trim();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedFilter(sampleFilter.trim()), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sampleFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTestPending(false);
|
||||
}, [testNeId]);
|
||||
|
||||
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta, enabled });
|
||||
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, enabled });
|
||||
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles, enabled });
|
||||
const sampleNeQuery = useQuery({
|
||||
queryKey: queryKeys.umeNE(debouncedFilter, 1, 5),
|
||||
queryFn: () => fetchUmeNe({ keyword: debouncedFilter, page: 1, pageSize: 5 }),
|
||||
enabled: enabled && sampleOpen,
|
||||
staleTime: 5000,
|
||||
});
|
||||
const sampleHits = useMemo(
|
||||
() => (sampleNeQuery.data?.items || []).slice(0, 5),
|
||||
[sampleNeQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sampleOpen) return;
|
||||
const el = sampleSuggestRef.current;
|
||||
if (!el) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const body = el.closest<HTMLElement>('[data-slot="modal-body"], .modal__body');
|
||||
if (body) {
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const overflow = elRect.bottom - bodyRect.bottom + 16;
|
||||
if (overflow > 0) {
|
||||
body.scrollBy({ top: overflow, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, 50);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sampleOpen, sampleHits.length, sampleNeQuery.isFetching]);
|
||||
const overrideQuery = useQuery({
|
||||
queryKey: queryKeys.umeCliOverride(testNeId),
|
||||
queryFn: () => fetchUmeCliOverride(testNeId),
|
||||
enabled: Boolean(testNeId),
|
||||
refetchInterval: (q) => {
|
||||
const status = String(q.state.data?.connect_status || "").toLowerCase();
|
||||
return status === "testing" || testPending ? 2000 : false;
|
||||
},
|
||||
});
|
||||
const overrideResult: UmeCliOverrideItem | null = overrideQuery.data ?? null;
|
||||
const overrideStatus = String(overrideResult?.connect_status || "").toLowerCase();
|
||||
const overrideTesting = overrideStatus === "testing" || (testPending && !overrideResult?.connect_tested_at);
|
||||
|
||||
useEffect(() => {
|
||||
const items = profilesQuery.data?.items || [];
|
||||
const current = items.find((x) => x.id === form.id);
|
||||
const picked = current || items.find((x) => x.is_default) || items[0];
|
||||
if (picked && (!form.id || !current)) {
|
||||
setForm(profileToForm(picked));
|
||||
}
|
||||
}, [profilesQuery.data, form.id]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (form.hop_enabled) {
|
||||
if (!form.hop.hop_host.trim()) throw new Error(t("managedNe.hop.hostRequired"));
|
||||
if (!form.hop.hop_username.trim()) throw new Error(t("managedNe.hop.userRequired"));
|
||||
if (!form.id && !form.hop.hop_password) throw new Error(t("managedNe.hop.passwordRequired"));
|
||||
}
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password || undefined,
|
||||
port: form.port,
|
||||
protocol: form.protocol,
|
||||
device_type_default: form.device_type_default,
|
||||
vendor_default: form.vendor_default,
|
||||
is_default: form.is_default,
|
||||
hop_enabled: form.hop_enabled,
|
||||
hop_vendor: form.hop.hop_vendor,
|
||||
hop_host: form.hop.hop_host,
|
||||
hop_port: form.hop.hop_port,
|
||||
hop_protocol: form.hop.hop_protocol,
|
||||
hop_username: form.hop.hop_username,
|
||||
hop_password: form.hop_enabled ? form.hop.hop_password || undefined : undefined,
|
||||
hop_command_template: form.hop.hop_command_template,
|
||||
hop_vrf: form.hop.hop_vrf,
|
||||
hop_target_auth_mode: form.hop.hop_target_auth_mode,
|
||||
hop_enter_system_view: form.hop.hop_enter_system_view,
|
||||
};
|
||||
if (form.id) {
|
||||
return apiPatch<CliConnectProfileItem>(`/v1/cli/profiles/${form.id}`, body);
|
||||
}
|
||||
return apiPost<CliConnectProfileItem>("/v1/cli/profiles", { ...body, password: form.password });
|
||||
},
|
||||
onSuccess: async (row) => {
|
||||
setForm(profileToForm(row));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.cliMeta });
|
||||
showOk(t("ume.cli.saved"));
|
||||
},
|
||||
onError: (e: Error) => showError(e.message),
|
||||
});
|
||||
|
||||
const connectTestMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const id = sampleUmeNeId.trim();
|
||||
if (!id) throw new Error(t("ume.cli.sampleNeRequired"));
|
||||
return postUmeConnectTest([id]);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const id = sampleUmeNeId.trim();
|
||||
setTestPending(true);
|
||||
showOk(t("ume.cli.connectTestSubmitted"));
|
||||
if (id) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeCliOverride(id) });
|
||||
}
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
setTestPending(false);
|
||||
showError(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!testPending) return;
|
||||
if (!overrideResult) return;
|
||||
if (overrideStatus === "testing") return;
|
||||
setTestPending(false);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cliTargetsAll });
|
||||
}, [testPending, overrideResult, overrideStatus, queryClient]);
|
||||
|
||||
const deviceTypes = neMetaQuery.data?.device_types ?? [];
|
||||
const vendors = neMetaQuery.data?.vendors ?? [];
|
||||
const cliReady = Boolean(metaQuery.data?.cli_profile_ready);
|
||||
|
||||
const body = (
|
||||
<div className={embedded ? "cli-connect-panel" : undefined}>
|
||||
{!embedded ? (
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.cli.title")}</h2>
|
||||
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
|
||||
</div>
|
||||
) : null}
|
||||
<p className="form-field-hint" style={{ marginBottom: 12 }}>
|
||||
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<TextField fullWidth value={form.name} onChange={(name) => setForm({ ...form, name })}>
|
||||
<Label>{t("ume.cli.profileName")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={form.username}
|
||||
onChange={(username) => setForm({ ...form, username })}
|
||||
>
|
||||
<Label>{t("ume.cli.targetUsername")}</Label>
|
||||
<Input placeholder="ca-oper" />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(password) => setForm({ ...form, password })}
|
||||
>
|
||||
<Label>{t("ume.cli.targetPassword")}</Label>
|
||||
<Input placeholder={form.id ? t("ume.cli.passwordKeep") : ""} />
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.form.deviceType")}
|
||||
value={form.device_type_default}
|
||||
onChange={(e) => setForm({ ...form, device_type_default: e.target.value })}
|
||||
>
|
||||
{deviceTypes.map((dt) => (
|
||||
<option key={dt} value={dt}>
|
||||
{dt}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
label={t("managedNe.form.vendor")}
|
||||
value={form.vendor_default}
|
||||
onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}
|
||||
>
|
||||
{vendors.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</div>
|
||||
<div className="form-fieldset" style={{ marginTop: 16 }}>
|
||||
<div className="form-fieldset__title">{t("managedNe.hop.sectionTitle")}</div>
|
||||
<Checkbox
|
||||
isSelected={form.hop_enabled}
|
||||
onChange={(hop_enabled) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hop_enabled,
|
||||
...(hop_enabled
|
||||
? {
|
||||
hop: {
|
||||
...prev.hop,
|
||||
...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
|
||||
...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
|
||||
</Checkbox>
|
||||
{form.hop_enabled ? (
|
||||
<div className="hop-proxy-fields">
|
||||
<HopProxyFields
|
||||
value={form.hop}
|
||||
onChange={(patch) => setForm((prev) => ({ ...prev, hop: { ...prev.hop, ...patch } }))}
|
||||
hopPasswordRequired={!form.id}
|
||||
hopPasswordOptional={Boolean(form.id)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="actions-row actions-row--inline" style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={saveMutation.isPending}
|
||||
onPress={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => setForm(emptyForm())}>
|
||||
{t("ume.cli.newProfile")}
|
||||
</Button>
|
||||
{form.id ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
void (async () => {
|
||||
if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
|
||||
try {
|
||||
await apiDelete(`/v1/cli/profiles/${form.id}`);
|
||||
setForm(emptyForm());
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
|
||||
showOk(t("ume.cli.deleted"));
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t("managedNe.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
|
||||
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
|
||||
<div className="cli-connect-test">
|
||||
<div className="cli-connect-test-row">
|
||||
<div className="cli-connect-test-pick">
|
||||
<Input
|
||||
value={sampleFilter}
|
||||
onChange={(e) => {
|
||||
setSampleFilter(e.target.value);
|
||||
setSampleUmeNeId("");
|
||||
setSampleOpen(true);
|
||||
}}
|
||||
onFocus={() => setSampleOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => setSampleOpen(false), 160);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setSampleOpen(false);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
if (e.key === "Enter" && sampleHits[0]) {
|
||||
e.preventDefault();
|
||||
const row = sampleHits[0];
|
||||
setSampleUmeNeId(row.ne_id);
|
||||
setSampleFilter(formatUmeNePickLabel(row));
|
||||
setSampleOpen(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("ume.cli.sampleNePh")}
|
||||
aria-label={t("ume.cli.connectTestTitle")}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={sampleOpen}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={connectTestMutation.isPending || !testNeId}
|
||||
onPress={() => connectTestMutation.mutate()}
|
||||
>
|
||||
{connectTestMutation.isPending || overrideTesting
|
||||
? t("managedNe.connect.running")
|
||||
: t("managedNe.connect.run")}
|
||||
</Button>
|
||||
</div>
|
||||
{sampleOpen ? (
|
||||
<div ref={sampleSuggestRef} className="cli-connect-test-suggest" role="listbox">
|
||||
{sampleNeQuery.isLoading ? (
|
||||
<div className="cli-connect-test-suggest__empty">{t("ume.cli.sampleNeLoading")}</div>
|
||||
) : sampleHits.length === 0 ? (
|
||||
<div className="cli-connect-test-suggest__empty">{t("ume.cli.sampleNeEmpty")}</div>
|
||||
) : (
|
||||
sampleHits.map((row) => {
|
||||
const title =
|
||||
String(row.user_label || row.ne_name || row.ne_id).trim() || row.ne_id;
|
||||
const meta = [row.ip_address, row.ne_type].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<button
|
||||
key={row.ne_id}
|
||||
type="button"
|
||||
className={`cli-connect-test-suggest__item${
|
||||
row.ne_id === sampleUmeNeId ? " is-active" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setSampleUmeNeId(row.ne_id);
|
||||
setSampleFilter(formatUmeNePickLabel(row));
|
||||
setSampleOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="cli-connect-test-suggest__name">{title}</span>
|
||||
<span className="cli-connect-test-suggest__meta">
|
||||
{meta || row.ne_id}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{testNeId ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="form-label">{t("ume.cli.connectTestResult")}</div>
|
||||
{overrideQuery.isLoading && !overrideResult ? (
|
||||
<p className="form-field-hint">{t("ume.cli.connectTestWaiting")}</p>
|
||||
) : overrideTesting ? (
|
||||
<p className="form-field-hint">
|
||||
<span className="inline-spinner" aria-hidden /> {t("ume.cli.connectTestRunning")}
|
||||
</p>
|
||||
) : overrideResult ? (
|
||||
<>
|
||||
<p style={{ marginTop: 8 }}>
|
||||
<span className={`conn-pill conn-pill--${connectPillLevel(overrideResult.connect_status)}`}>
|
||||
{overrideResult.connect_status}
|
||||
</span>
|
||||
{overrideResult.connect_message ? (
|
||||
<span className="connect-detail-summary"> — {overrideResult.connect_message}</span>
|
||||
) : null}
|
||||
{overrideResult.connect_tested_at ? (
|
||||
<span className="muted" style={{ marginLeft: 8 }}>
|
||||
{formatSystemTime(overrideResult.connect_tested_at, { assumeUtcNaive: true })}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<pre className="connect-log">
|
||||
{overrideResult.connect_detail?.trim() ||
|
||||
overrideResult.connect_message?.trim() ||
|
||||
t("managedNe.connectDetailEmpty")}
|
||||
</pre>
|
||||
</>
|
||||
) : testPending ? (
|
||||
<p className="form-field-hint">{t("ume.cli.connectTestWaiting")}</p>
|
||||
) : (
|
||||
<p className="form-field-hint">{t("managedNe.connectDetailEmpty")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(profilesQuery.data?.items || []).length > 1 ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<span className="form-label">{t("ume.cli.existingProfiles")}</span>
|
||||
<div className="actions-row actions-row--inline">
|
||||
{(profilesQuery.data?.items || []).map((p) => (
|
||||
<Button key={p.id} size="sm" variant="ghost" onPress={() => setForm(profileToForm(p))}>
|
||||
{p.name}
|
||||
{p.is_default ? ` (${t("ume.cli.default")})` : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) return body;
|
||||
return <section className="panel">{body}</section>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,61 +0,0 @@
|
|||
import type { ModuleIconKind, ModuleIconTone } from "../config/modules";
|
||||
|
||||
type Props = {
|
||||
tone: ModuleIconTone;
|
||||
kind: ModuleIconKind;
|
||||
};
|
||||
|
||||
function IconPath({ kind }: { kind: ModuleIconKind }) {
|
||||
switch (kind) {
|
||||
case "sync":
|
||||
return (
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" />
|
||||
);
|
||||
case "server":
|
||||
return (
|
||||
<path d="M4 4h16a1 1 0 0 1 1 1v5H3V5a1 1 0 0 1 1-1zm-1 8h18v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5zm3 1.25a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm0-8a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM8 6.5h6v1.25H8V6.5zm0 8h6v1.25H8V14.5z" />
|
||||
);
|
||||
case "network":
|
||||
return (
|
||||
<path d="M12 2a3 3 0 0 1 1 5.83V10h3a3 3 0 1 1 0 2h-3v2.17A3.001 3.001 0 1 1 11 17.83V14H8a3 3 0 1 1 0-2h3v-2.17A3.001 3.001 0 0 1 12 2zm0 2a1 1 0 1 0 .001 2.001A1 1 0 0 0 12 4zM6 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
|
||||
);
|
||||
case "topology":
|
||||
return (
|
||||
<path d="M7 3a3 3 0 0 1 2.83 4H11v2h2V7h1.17A3.001 3.001 0 1 1 17 10.83V12h2v2h-2v1.17A3.001 3.001 0 1 1 13.17 18H12v-2h-1.17A3.001 3.001 0 1 1 7 13.17V12H5v-2h2V8.83A3.001 3.001 0 0 1 7 3zm10 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM7 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm0 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
|
||||
);
|
||||
case "terminal":
|
||||
return (
|
||||
<path d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5zm2 0v14h12V5H6zm2.3 3.2 1.4-1.4L13.1 10l-3.4 3.2-1.4-1.4L10.3 10 8.3 8.2zM12 15h4v1.8h-4V15z" />
|
||||
);
|
||||
case "wall":
|
||||
return (
|
||||
<path d="M3 5h8v6H3V5zm10 0h8v6h-8V5zM3 13h8v6H3v-6zm10 0h8v6h-8v-6zm2-6v2h4V7h-4zm0 8v2h4v-2h-4zM5 7v2h4V7H5zm0 8v2h4v-2H5z" />
|
||||
);
|
||||
case "users":
|
||||
return (
|
||||
<path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0 2c-3.33 0-6 1.34-6 3v1h12v-1c0-1.66-2.67-3-6-3zm7.5-5.5a2.5 2.5 0 1 0-2.45-3H17a2.5 2.5 0 0 0 2.5 3.5zM19 12c1.66 0 3 .67 3 1.5V14h-2.1c-.3-.7-1-1.2-1.9-1.5v-.5z" />
|
||||
);
|
||||
case "audit":
|
||||
return (
|
||||
<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7 1.5V9h4.5L14 4.5zM9 12h6v1.5H9V12zm0 3.5h6V17H9v-1.5zm0-7h3V10H9V8.5z" />
|
||||
);
|
||||
case "key":
|
||||
return (
|
||||
<path d="M12.65 10A5.99 5.99 0 0 0 7 4a6 6 0 0 0-1 11.92V20l2 2 2-2 1-1v-2.08c.33.05.66.08 1 .08a6 6 0 0 0 5.65-8H12.65zM7 8a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkbenchCardIcon({ tone, kind }: Props) {
|
||||
return (
|
||||
<span className={`wb-card__icon wb-card__icon--${tone}`} aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<IconPath kind={kind} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { AppModalShell, TopoModalShell, type AppModalShellProps } from "./AppModalShell";
|
||||
Loading…
Add table
Add a link
Reference in a new issue