feat(cli): UME lazy exec via shared profiles and ume_ne_id MCP support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 17:13:49 +08:00
parent ba1a40f725
commit 38a1c7f3a9
21 changed files with 1504 additions and 54 deletions

View file

@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
apiDelete,
apiPatch,
apiPost,
fetchCliMeta,
fetchCliProfiles,
fetchManagedNeMeta,
postUmeConnectTest,
} from "../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
import { HelpHint } from "./HelpHint";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CliConnectProfileItem } from "../types";
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: HopProxyFieldsState;
};
const emptyForm = (): ProfileForm => ({
id: "",
name: "default",
username: "",
password: "",
port: 22,
protocol: "ssh",
device_type_default: "zte_zxros",
vendor_default: "ZTE",
is_default: true,
hop: { ...emptyHopProxyFields(), hop_vendor: "bastion", hop_port: 22 },
});
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: {
hop_vendor: row.hop_vendor as HopProxyFieldsState["hop_vendor"],
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: row.hop_command_template,
hop_vrf: row.hop_vrf,
hop_target_auth_mode: row.hop_target_auth_mode,
},
};
}
export function UmeCliConnectPanel() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState<ProfileForm>(emptyForm);
const [sampleUmeNeId, setSampleUmeNeId] = useState("");
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta });
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta });
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles });
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 () => {
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.hop_vendor !== "linux" ? Boolean(form.hop.hop_host.trim()) : true,
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.hop_password || 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,
};
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: () => {
showOk(t("ume.cli.connectTestSubmitted"));
},
onError: (e: Error) => showError(e.message),
});
const deviceTypes = neMetaQuery.data?.device_types ?? [];
const vendors = neMetaQuery.data?.vendors ?? [];
const cliReady = Boolean(metaQuery.data?.cli_profile_ready);
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.cli.title")}</h2>
<HelpHint text={t("ume.cli.hint")} />
</div>
<p className="form-field-hint" style={{ marginBottom: 12 }}>
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
</p>
<div className="form-grid" style={{ maxWidth: 960 }}>
<label>
<span className="form-label">{t("ume.cli.profileName")}</span>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label>
<span className="form-label">{t("ume.cli.targetUsername")}</span>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
placeholder="ca-oper"
/>
</label>
<label>
<span className="form-label">{t("ume.cli.targetPassword")}</span>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
placeholder={form.id ? t("ume.cli.passwordKeep") : ""}
/>
</label>
<label>
<span className="form-label">{t("managedNe.form.deviceType")}</span>
<select
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>
))}
</select>
</label>
<label>
<span className="form-label">{t("managedNe.form.vendor")}</span>
<select value={form.vendor_default} onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
</div>
<HopProxyFields
value={form.hop}
onChange={(hop) => setForm({ ...form, hop })}
hopPasswordRequired={!form.id}
hopPasswordOptional={Boolean(form.id)}
/>
<div className="filter-inline" style={{ marginTop: 16 }}>
<button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
<button
type="button"
onClick={() => {
setForm(emptyForm());
}}
>
{t("ume.cli.newProfile")}
</button>
{form.id ? (
<button
type="button"
className="danger-btn"
onClick={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="panel" style={{ marginTop: 20 }}>
<h3>{t("ume.cli.connectTestTitle")}</h3>
<div className="filter-inline">
<input
value={sampleUmeNeId}
onChange={(e) => setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")}
style={{ minWidth: 320 }}
/>
<button type="button" onClick={() => connectTestMutation.mutate()} disabled={connectTestMutation.isPending}>
{t("managedNe.connect.run")}
</button>
</div>
</div>
{(profilesQuery.data?.items || []).length > 1 ? (
<div style={{ marginTop: 16 }}>
<span className="form-label">{t("ume.cli.existingProfiles")}</span>
<div className="filter-inline">
{(profilesQuery.data?.items || []).map((p) => (
<button key={p.id} type="button" className="link-btn" onClick={() => setForm(profileToForm(p))}>
{p.name}
{p.is_default ? ` (${t("ume.cli.default")})` : ""}
</button>
))}
</div>
</div>
) : null}
</section>
);
}

View file

@ -32,6 +32,10 @@ export const queryKeys = {
neCollectionRunsAll: ["neCollectionRuns"] as const,
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
["neCollectionRuns", jobId, page, status, keyword] as const,
cliMeta: ["cliMeta"] as const,
cliProfiles: ["cliProfiles"] as const,
cliTargets: (keyword: string, page: number, pageSize: number) =>
["cliTargets", keyword, page, pageSize] as const,
umeCurrentAlarms: (
severity: string,
cleared: string,

View file

@ -465,6 +465,31 @@ const en = {
clearTitle: "Clear keyword and reset to page 1",
expand: "Expand details",
collapse: "Collapse details",
cliConnect: "CLI connect",
},
tabs: {
main: "UME sync",
cli: "CLI connect",
},
cli: {
title: "UME CLI connection profile",
hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.",
statusReady: "CLI profile is ready for UME NE command execution.",
statusNotReady: "Set target username and hop/bastion settings (if enabled).",
profileName: "Profile name",
targetUsername: "Target SSH username",
targetPassword: "Target SSH password",
passwordKeep: "Leave blank to keep unchanged",
newProfile: "New profile",
deleteConfirm: "Delete this CLI profile?",
deleted: "Profile deleted",
saved: "CLI profile saved",
connectTestTitle: "Sample NE connectivity test",
sampleNePh: "ume_ne_id (copy from NE list below)",
sampleNeRequired: "ume_ne_id is required",
connectTestSubmitted: "Connectivity test submitted",
existingProfiles: "Existing profiles",
default: "default",
},
alarms: {
title: "Current alarms",

View file

@ -463,6 +463,31 @@ const zh = {
clearTitle: "清空 keyword,回到第 1 页",
expand: "展开详情",
collapse: "收起详情",
cliConnect: "CLI 连通",
},
tabs: {
main: "UME 同步",
cli: "CLI 连接",
},
cli: {
title: "UME CLI 连接配置",
hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。",
statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。",
statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",
profileName: "配置名称",
targetUsername: "目标 SSH 用户名",
targetPassword: "目标 SSH 密码",
passwordKeep: "留空则不修改",
newProfile: "新建配置",
deleteConfirm: "确定删除该 CLI 配置?",
deleted: "配置已删除",
saved: "CLI 配置已保存",
connectTestTitle: "样本网元连通性测试",
sampleNePh: "ume_ne_id(从下方网元清单复制)",
sampleNeRequired: "请填写 ume_ne_id",
connectTestSubmitted: "已提交连通性测试",
existingProfiles: "已有配置",
default: "默认",
},
alarms: {
title: "当前告警",

View file

@ -1,4 +1,4 @@
import { Fragment, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
apiPost,
@ -15,6 +15,7 @@ import {
fetchUmeNotificationIds,
fetchUmeSyncStatus,
fetchUmeTokenStatus,
fetchCliTargets,
refreshUmeToken,
upsertUmeKeyAlertRule,
deleteUmeKeyAlertRule,
@ -22,6 +23,7 @@ import {
updateUmeKeyAlertMonitorConfig,
} from "../services/api";
import { HelpHint } from "../components/HelpHint";
import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
@ -67,6 +69,7 @@ export function UmePage() {
const [keyAlertNeTypes, setKeyAlertNeTypes] = useState<string[]>([]);
const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null);
const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]);
const [umeViewTab, setUmeViewTab] = useState<"main" | "cli">("main");
const syncMutation = useMutation({
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
@ -254,6 +257,20 @@ export function UmePage() {
queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }),
staleTime: 5000,
});
const cliTargetsQuery = useQuery({
queryKey: queryKeys.cliTargets(neKeyword, nePage, nePageSize),
queryFn: () =>
fetchCliTargets({ source: "ume", keyword: neKeyword, page: nePage, pageSize: nePageSize }),
enabled: nePanelOpen && umeViewTab === "main",
staleTime: 5000,
});
const cliStatusByNeId = useMemo(() => {
const map = new Map<string, string>();
for (const row of cliTargetsQuery.data?.items || []) {
map.set(row.id, row.connect_status);
}
return map;
}, [cliTargetsQuery.data]);
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize),
queryFn: () =>
@ -442,6 +459,30 @@ export function UmePage() {
return (
<>
<section className="panel" style={{ marginBottom: 12 }}>
<div className="filter-inline">
<button
type="button"
className={umeViewTab === "main" ? "link-btn" : "link-btn"}
style={{ fontWeight: umeViewTab === "main" ? 700 : 400 }}
onClick={() => setUmeViewTab("main")}
>
{t("ume.tabs.main")}
</button>
<button
type="button"
className="link-btn"
style={{ fontWeight: umeViewTab === "cli" ? 700 : 400 }}
onClick={() => setUmeViewTab("cli")}
>
{t("ume.tabs.cli")}
</button>
</div>
</section>
{umeViewTab === "cli" ? (
<UmeCliConnectPanel />
) : (
<>
<section className="cards">
<article className="card card--full">
<h3>{t("ume.token.title")}</h3>
@ -1245,6 +1286,7 @@ export function UmePage() {
<th>type</th>
<th>device_level</th>
<th>host_name</th>
<th>{t("ume.ne.cliConnect")}</th>
<th>hw_ver</th>
<th>last_seen</th>
</tr>
@ -1267,12 +1309,13 @@ export function UmePage() {
<td>{x.ne_type}</td>
<td>{x.device_level || t("common.empty")}</td>
<td>{x.host_name || t("common.empty")}</td>
<td>{cliStatusByNeId.get(x.ne_id) || "unknown"}</td>
<td>{x.hardware_version || t("common.empty")}</td>
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td>
</tr>
{expandedNeId === x.ne_id ? (
<tr>
<td colSpan={8}>
<td colSpan={9}>
<div style={{ fontSize: 12, display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }}>
<div>consistent_state: {x.consistent_state || t("common.empty")}</div>
<div>admin_status: {x.admin_status || t("common.empty")}</div>
@ -1511,5 +1554,7 @@ export function UmePage() {
</div>
) : null}
</>
)}
</>
);
}

View file

@ -14,6 +14,9 @@ import type {
UmeNeItem,
UmeSyncStatusResponse,
UmeTokenStatus,
CliConnectProfileItem,
CliMeta,
CliTargetListResponse,
} from "../types";
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
@ -306,6 +309,28 @@ export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/r
export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`;
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
export const fetchCliProfiles = () =>
apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles");
export const fetchCliTargets = (params: {
source?: "managed" | "ume" | "all";
keyword?: string;
page?: number;
pageSize?: number;
}) => {
const p = new URLSearchParams();
p.set("source", params.source || "all");
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
return apiGet<CliTargetListResponse>(`/v1/cli/targets?${p.toString()}`);
};
export const postUmeConnectTest = (umeNeIds: string[]) =>
apiPost<{ ok: boolean; submitted: number }>("/v1/cli/ume-connect-test", { ume_ne_ids: umeNeIds });
export const fetchUmeCurrentAlarms = (params: {
severity: string;
isCleared: string;

View file

@ -219,6 +219,55 @@ export type ManagedNeImportResult = {
failed: Array<{ row: number; reason: string }>;
};
export type CliConnectProfileItem = {
id: string;
name: string;
is_default: boolean;
username: string;
port: number;
protocol: string;
device_type_default: string;
vendor_default: string;
ne_type_rules: string;
hop_enabled: boolean;
hop_vendor: string;
hop_host: string;
hop_port: number;
hop_protocol: string;
hop_username: string;
hop_command_template: string;
hop_vrf: string;
hop_target_auth_mode: string;
created_at: string;
updated_at: string;
};
export type CliMeta = {
credentials_configured: boolean;
default_profile_configured: boolean;
cli_profile_ready: boolean;
};
export type CliTargetItem = {
source: string;
id: string;
ume_ne_id?: string | null;
name: string;
ip_address: string;
ne_type?: string;
vendor?: string;
device_type?: string;
connect_status: string;
cli_profile_ready?: boolean;
};
export type CliTargetListResponse = {
total: number;
page: number;
page_size: number;
items: CliTargetItem[];
};
export type EligibleNeItem = {
id: string;
name: string;