diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx index e92e542..4be38fc 100644 --- a/web/src/components/UmeCliConnectPanel.tsx +++ b/web/src/components/UmeCliConnectPanel.tsx @@ -16,6 +16,7 @@ import { queryKeys } from "../constants/queryKeys"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import type { CliConnectProfileItem, UmeCliOverrideItem } from "../types"; +import { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange } from "../utils/hopProxy"; import { formatSystemTime } from "../utils/time"; function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" { @@ -36,9 +37,17 @@ type ProfileForm = { 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 { + 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", @@ -49,7 +58,8 @@ const emptyForm = (): ProfileForm => ({ device_type_default: "zte_zxros", vendor_default: "ZTE", is_default: true, - hop: { ...emptyHopProxyFields(), hop_vendor: "bastion", hop_port: 22 }, + hop_enabled: false, + hop: emptyHopProxyFields(), }); function profileToForm(row: CliConnectProfileItem): ProfileForm { @@ -63,6 +73,7 @@ function profileToForm(row: CliConnectProfileItem): ProfileForm { 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 ?? "", @@ -77,7 +88,7 @@ function profileToForm(row: CliConnectProfileItem): ProfileForm { }; } -export function UmeCliConnectPanel() { +export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabled?: boolean; embedded?: boolean }) { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); @@ -91,9 +102,9 @@ export function UmeCliConnectPanel() { setTestPending(false); }, [testNeId]); - const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta }); - const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta }); - const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles }); + 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 overrideQuery = useQuery({ queryKey: queryKeys.umeCliOverride(testNeId), queryFn: () => fetchUmeCliOverride(testNeId), @@ -118,6 +129,11 @@ export function UmeCliConnectPanel() { 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(), @@ -127,14 +143,13 @@ export function UmeCliConnectPanel() { 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_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.hop_password || undefined, + 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, @@ -185,16 +200,18 @@ export function UmeCliConnectPanel() { const vendors = neMetaQuery.data?.vendors ?? []; const cliReady = Boolean(metaQuery.data?.cli_profile_ready); - return ( -
-
-

{t("ume.cli.title")}

- -
+ const body = ( +
+ {!embedded ? ( +
+

{t("ume.cli.title")}

+ +
+ ) : null}

{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}

-
+
- setForm((prev) => ({ ...prev, hop: { ...prev.hop, ...patch } }))} - hopPasswordRequired={!form.id} - hopPasswordOptional={Boolean(form.id)} - /> -
+
+
{t("managedNe.hop.sectionTitle")}
+ + {form.hop_enabled ? ( +
+ setForm((prev) => ({ ...prev, hop: { ...prev.hop, ...patch } }))} + hopPasswordRequired={!form.id} + hopPasswordOptional={Boolean(form.id)} + /> +
+ ) : null} +
+
@@ -279,14 +326,13 @@ export function UmeCliConnectPanel() { ) : null}
-
-

{t("ume.cli.connectTestTitle")}

-
+
+

{t("ume.cli.connectTestTitle")}

+
setSampleUmeNeId(e.target.value)} placeholder={t("ume.cli.sampleNePh")} - style={{ minWidth: 320 }} />
) : null} -
+ ); + + if (embedded) return body; + return
{body}
; } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 27e7975..c314c79 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -337,6 +337,8 @@ const en = { }, keyAlert: { title: "AI alarm monitor (OClaw WhatsApp)", + showPanel: "Expand", + hidePanel: "Collapse", help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.). Optionally restrict to UME inventory ne_type values (multi-select; empty = all devices). Pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.", ws: "OClaw WSS", wsConnected: "connected", @@ -455,6 +457,8 @@ const en = { }, syncStatus: { title: "Sync history", + showPanel: "Expand", + hidePanel: "Collapse", empty: "No sync records", }, ne: { @@ -473,6 +477,8 @@ const en = { }, cli: { title: "UME CLI connection profile", + showPanel: "Expand", + hidePanel: "Collapse", 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).", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index d0b4641..ba05474 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -335,6 +335,8 @@ const zh = { }, keyAlert: { title: "AI 告警监控(OClaw WhatsApp)", + showPanel: "展开", + hidePanel: "收起", help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段);可选限定 UME Inventory 中的网元类型 (ne_type),不选则匹配全部设备。经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。", ws: "OClaw WSS", wsConnected: "已连接", @@ -453,6 +455,8 @@ const zh = { }, syncStatus: { title: "同步状态", + showPanel: "展开", + hidePanel: "收起", empty: "暂无同步记录", }, ne: { @@ -471,6 +475,8 @@ const zh = { }, cli: { title: "UME CLI 连接配置", + showPanel: "展开", + hidePanel: "收起", hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。", statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。", statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。", diff --git a/web/src/index.css b/web/src/index.css index dcab946..0e9c0e5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -355,13 +355,26 @@ button { font-size: 16px; } -.card input, +.card__section-title { + margin: 14px 0 8px; + font-size: 14px; + font-weight: 600; +} + +.card input:not([type="checkbox"]):not([type="radio"]), .card select, .card textarea { width: 100%; margin-bottom: 8px; } +.card input[type="checkbox"], +.card input[type="radio"] { + width: auto; + margin-bottom: 0; + flex: 0 0 auto; +} + .pager input, .pager select { width: auto; @@ -902,7 +915,11 @@ pre { .page-stack { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; +} + +.page-stack > .cards { + margin-bottom: 0; } .panel--warn { @@ -920,7 +937,8 @@ pre { margin-bottom: 12px; } -.panel__toolbar h2 { +.panel__toolbar h2, +.panel__toolbar h3 { margin: 0; } @@ -1008,6 +1026,104 @@ pre { margin-top: 16px; } +.cli-connect-panel { + width: 100%; + text-align: left; +} + +.cli-connect-panel .form-grid, +.cli-connect-panel .form-fieldset { + width: 100%; + max-width: none; +} + +.cli-connect-panel .form-grid label:not(.form-check) { + align-items: stretch; + text-align: left; +} + +.cli-connect-panel .form-label { + display: block; + margin: 0; + text-align: left; +} + +.cli-connect-panel .form-grid input, +.cli-connect-panel .form-grid select, +.cli-connect-panel .form-fieldset input, +.cli-connect-panel .form-fieldset select { + width: 100%; + margin: 0; + text-align: left; +} + +.cli-connect-panel .form-fieldset { + text-align: left; + width: 100%; + min-width: 0; + min-inline-size: 0; +} + +.cli-connect-panel .hop-proxy-fields, +.cli-connect-panel .hop-proxy-fields .form-grid { + width: 100%; +} + +.cli-connect-panel .hop-proxy-fields .form-grid label { + align-items: flex-start; + justify-content: flex-start; + text-align: left; + width: 100%; +} + +.cli-connect-panel .hop-proxy-fields .form-grid select, +.cli-connect-panel .hop-proxy-fields .form-grid input { + display: block; + width: 100%; + max-width: 100%; + margin: 0; + box-sizing: border-box; + text-align: left; + text-align-last: left; +} + +.cli-connect-panel .hop-proxy-fields .form-field-hint { + text-align: left; +} + +.cli-connect-panel .form-fieldset legend { + margin: 0 0 8px; + padding: 0 6px; + text-align: left; +} + +.cli-connect-panel .form-check { + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + max-width: 100%; +} + +.card .cli-connect-panel input, +.card .cli-connect-panel select, +.card .cli-connect-panel textarea { + margin-bottom: 0; +} + +.cli-connect-test-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.cli-connect-test-row input { + flex: 1 1 320px; + min-width: 240px; + margin-bottom: 0; +} + .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1036,6 +1152,14 @@ pre { line-height: 1.35; } +.form-label { + display: block; + margin: 0; + font-size: 12px; + color: #64748b; + text-align: left; +} + .form-grid label { display: flex; flex-direction: column; @@ -1060,13 +1184,21 @@ pre { border-radius: 8px; } -.form-fieldset legend { +.form-fieldset legend, +.form-fieldset__title { padding: 0 6px; font-size: 13px; font-weight: 600; color: #334155; } +.form-fieldset__title { + display: block; + margin: 0 0 10px; + padding: 0; + text-align: left; +} + .form-check { display: flex; flex-direction: row; @@ -1075,6 +1207,21 @@ pre { margin-bottom: 12px; font-size: 13px; color: #334155; + width: fit-content; + max-width: 100%; +} + +.form-check__text { + white-space: nowrap; + line-height: 1.4; +} + +/* form-grid labels are column layout; checkbox rows must stay horizontal */ +.form-grid label.form-check { + flex-direction: row; + align-items: center; + width: fit-content; + max-width: 100%; } .table-tag { diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index d690fb8..a1510ba 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -601,6 +601,7 @@ export function NePage() { {t("managedNe.col.name")} {t("managedNe.col.vendor")} {t("managedNe.col.deviceType")} + {t("managedNe.col.tags")} {t("managedNe.col.ip")} {t("managedNe.col.user")} {t("managedNe.col.connect")} @@ -621,6 +622,15 @@ export function NePage() { {row.name || row.ip_address} {row.vendor} {row.device_type} + + {row.tags + ? row.tags.split(/\s+/).map((tag) => ( + + {tag} + + )) + : t("common.empty")} + {row.ip_address}:{row.port}/{row.protocol} {row.hop_enabled ? ( @@ -808,7 +818,7 @@ export function NePage() { })); }} /> - {t("managedNe.hop.enable")} + {t("managedNe.hop.enable")} {form.hop_enabled ? ( ([]); const [keyAlertEditRule, setKeyAlertEditRule] = useState(null); const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState([]); - const [umeViewTab, setUmeViewTab] = useState<"main" | "cli">("main"); - - const syncMutation = useMutation({ - mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeCurrentAlarmsAll }); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeInventoryNeTypes }); - }, - }); + const [cliPanelOpen, setCliPanelOpen] = useState(false); const syncStatusQuery = useQuery({ queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize), @@ -138,6 +130,7 @@ export function UmePage() { }), staleTime: 3000, refetchInterval: 5000, + enabled: keyAlertPanelOpen, }); const keyAlertNeTypesQuery = useQuery({ queryKey: queryKeys.umeInventoryNeTypes, @@ -261,7 +254,7 @@ export function UmePage() { queryKey: queryKeys.cliTargets(neKeyword, nePage, nePageSize), queryFn: () => fetchCliTargets({ source: "ume", keyword: neKeyword, page: nePage, pageSize: nePageSize }), - enabled: nePanelOpen && umeViewTab === "main", + enabled: nePanelOpen, staleTime: 5000, }); const cliStatusByNeId = useMemo(() => { @@ -459,30 +452,7 @@ export function UmePage() { return ( <> -
-
- - -
-
- {umeViewTab === "cli" ? ( - - ) : ( - <> +

{t("ume.token.title")}

@@ -562,6 +532,105 @@ export function UmePage() { )}
+
+

{t("ume.tasks.currentTitle")}

+
+ 0 ? "unknown" : "up"}`}> + {t("ume.tasks.running")}: {runningTasks.length} + + +
+ + + + + + + + + + + + + {runningTasks.map((x) => ( + + + + + + + + + ))} + {!syncStatusQuery.isLoading && runningTasks.length === 0 && ( + + + + )} + +
IDdomaintrigger_modestatusstarted_aterror
{x.id}{x.domain}{x.trigger_mode}{x.status}{formatSystemTime(x.started_at)}{x.error_message || t("common.empty")}
{t("ume.tasks.noRunning")}
+ +

{t("ume.tasks.runtimeTitle")}

+ {runtimeTaskError ? ( +
+ {t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError} +
+ ) : null} + + + + + + + + + + + + + {runtimeTasks.map((x) => ( + + + + + + + + + ))} + {!syncStatusQuery.isLoading && runtimeTasks.length === 0 && ( + + + + )} + +
taskintervalstatuslast_run_at{t("ume.tasks.lastErrorCol")}actions
{runtimeTaskLabel(x.task)} + {runtimeIntervalLabel(x.interval_label)} + {x.status}{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}{runtimeLastError(x.last_error, t) || t("common.empty")} + {Boolean(x.paused) ? ( + + ) : ( + + )} +
{t("ume.tasks.noRuntime")}
+
+

{t("ume.subscription.title")} @@ -711,10 +780,30 @@ export function UmePage() {

-

- {t("ume.keyAlert.title")} - -

+
+

+ {t("ume.cli.title")} + +

+ +
+ {cliPanelOpen ? : null} +
+ +
+
+

+ {t("ume.keyAlert.title")} + +

+ +
+ {keyAlertPanelOpen ? ( + <>
{t("ume.keyAlert.ws")}:{" "} @@ -1059,130 +1148,20 @@ export function UmePage() {
- - -
-

{t("ume.sync.title")}

-
- - - -
- {syncMutation.error && ( -
- {t("ume.sync.failed")}: {String(syncMutation.error)} -
- )} + + ) : null}
-

{t("ume.tasks.currentTitle")}

-
- 0 ? "unknown" : "up"}`}> - {t("ume.tasks.running")}: {runningTasks.length} - -
- - - - - - - - - - - - - {runningTasks.map((x) => ( - - - - - - - - - ))} - {!syncStatusQuery.isLoading && runningTasks.length === 0 && ( - - - - )} - -
IDdomaintrigger_modestatusstarted_aterror
{x.id}{x.domain}{x.trigger_mode}{x.status}{formatSystemTime(x.started_at)}{x.error_message || t("common.empty")}
{t("ume.tasks.noRunning")}
- -

{t("ume.tasks.runtimeTitle")}

- {runtimeTaskError ? ( -
- {t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError} -
- ) : null} - - - - - - - - - - - - - {runtimeTasks.map((x) => ( - - - - - - - - - ))} - {!syncStatusQuery.isLoading && runtimeTasks.length === 0 && ( - - - - )} - -
taskintervalstatuslast_run_at{t("ume.tasks.lastErrorCol")}actions
{runtimeTaskLabel(x.task)} - {runtimeIntervalLabel(x.interval_label)} - {x.status}{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}{runtimeLastError(x.last_error, t) || t("common.empty")} - {Boolean(x.paused) ? ( - - ) : ( - - )} -
{t("ume.tasks.noRuntime")}
-
- -
-

{t("ume.syncStatus.title")}

+ {syncStatusPanelOpen ? ( + <>
+ + ) : null}
@@ -1476,6 +1457,7 @@ export function UmePage() { ) : null}
+ {keyAlertEditRule ? (
setKeyAlertEditRule(null)}> @@ -1554,7 +1536,5 @@ export function UmePage() {
) : null} - )} - ); }