You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@hertzbeat.apache.org by zh...@apache.org on 2026/06/03 08:18:14 UTC
(hertzbeat) 04/50: feat(web-next): restore monitor editor parity
This is an automated email from the ASF dual-hosted git repository.
zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 388b76446e7444524ee058c7456fcf65d4328eca
Author: Logic <zq...@dromara.org>
AuthorDate: Fri May 29 00:21:14 2026 +0800
feat(web-next): restore monitor editor parity
---
.../[monitorId]/edit/monitor-edit-page.tsx | 103 ++
.../app/monitors/[monitorId]/edit/page.test.tsx | 154 ++-
web-next/app/monitors/[monitorId]/edit/page.tsx | 49 +-
web-next/app/monitors/new/monitor-new-page.tsx | 71 ++
web-next/app/monitors/new/page.test.tsx | 158 ++-
web-next/app/monitors/new/page.tsx | 53 +-
.../pages/monitor-editor-surface.test.tsx | 1280 +++++++++++++++++++-
.../components/pages/monitor-editor-surface.tsx | 1150 +++++++++++++-----
web-next/lib/monitor-api-facade.test.ts | 76 ++
web-next/lib/monitor-api-facade.ts | 84 ++
web-next/lib/monitor-editor/controller.test.ts | 316 ++++-
web-next/lib/monitor-editor/controller.ts | 197 ++-
web-next/lib/monitor-editor/navigation.test.ts | 9 +-
web-next/lib/monitor-editor/navigation.ts | 4 +
web-next/lib/monitor-editor/query-state.test.ts | 107 ++
web-next/lib/monitor-editor/query-state.ts | 57 +
16 files changed, 3396 insertions(+), 472 deletions(-)
diff --git a/web-next/app/monitors/[monitorId]/edit/monitor-edit-page.tsx b/web-next/app/monitors/[monitorId]/edit/monitor-edit-page.tsx
new file mode 100644
index 0000000000..9a1dde940e
--- /dev/null
+++ b/web-next/app/monitors/[monitorId]/edit/monitor-edit-page.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import React, { useCallback } from 'react';
+import { HzButton, HzInlineFeedback } from '@hertzbeat/ui';
+import { ClientWorkbench } from '@/components/workbench/client-workbench';
+import { MonitorEditorSurface } from '../../../../components/pages/monitor-editor-surface';
+import { useI18n } from '@/components/providers/i18n-provider';
+import { api } from '@/lib/monitor-api-facade';
+import { buildMonitorEditCacheKey, loadMonitorEditorDraftFromFacade } from '@/lib/monitor-editor/controller';
+import type { MonitorEditRouteState } from '@/lib/monitor-editor/query-state';
+
+const MONITOR_EDIT_SETTLED_CACHE_TTL_MS = 10_000;
+const EMPTY_MONITOR_EDIT_ROUTE_STATE: MonitorEditRouteState = {
+ returnContext: {}
+};
+
+export default function MonitorEditPage({
+ monitorId,
+ initialRouteState
+}: {
+ monitorId: string;
+ initialRouteState?: MonitorEditRouteState;
+}) {
+ const { t } = useI18n();
+ const monitorEditRouteState = initialRouteState ?? EMPTY_MONITOR_EDIT_ROUTE_STATE;
+ const { returnContext } = monitorEditRouteState;
+ const monitorEditCacheKey = React.useMemo(() => buildMonitorEditCacheKey(monitorId), [monitorId]);
+
+ const load = useCallback(async () => {
+ return loadMonitorEditorDraftFromFacade(
+ {
+ readMonitorDetail: api.monitors.editorDetail,
+ readCollectors: api.monitors.editorCollectors,
+ readParamDefines: api.monitors.editorParamDefines
+ },
+ 'edit',
+ { monitorId }
+ );
+ }, [monitorId]);
+
+ return (
+ <div
+ data-monitor-editor-app-source-contract="angular-route-context-hidden-field"
+ data-monitor-editor-static-host-position-contract="angular-before-name"
+ data-monitor-editor-field-order-contract="angular-monitor-form-sequence"
+ data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"
+ data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"
+ data-monitor-editor-payload-param-merge-contract="angular-params-advanced-sdparams"
+ data-monitor-editor-payload-host-instance-contract="angular-host-param-as-instance"
+ data-monitor-editor-service-discovery-params="angular-nonstatic-only"
+ data-monitor-editor-ssl-port-notice="angular-info-notification"
+ data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"
+ data-monitor-editor-label-selector="angular-app-label-selector"
+ >
+ <ClientWorkbench
+ load={load}
+ loadingCopy={t('monitor.editor.loading.edit')}
+ cacheKey={monitorEditCacheKey}
+ cacheSettledTtlMs={MONITOR_EDIT_SETTLED_CACHE_TTL_MS}
+ renderError={(message, retry) => (
+ <section
+ className="mx-auto w-full max-w-[1320px] px-3 py-6"
+ data-monitor-editor-route-state="error"
+ data-monitor-editor-route-state-owner="hertzbeat-ui-inline-feedback"
+ data-monitor-editor-edit-error-state="angular-route-state"
+ >
+ <HzInlineFeedback
+ tone="critical"
+ title={t('monitor.route-state.form.error.title')}
+ description={
+ <>
+ {t('monitor.route-state.form.error.copy')}
+ <span className="sr-only">{message}</span>
+ </>
+ }
+ variant="embedded"
+ data-monitor-editor-route-state-feedback="error"
+ />
+ <div className="mt-3 flex justify-center">
+ <HzButton
+ size="sm"
+ intent="primary"
+ onClick={retry}
+ data-monitor-editor-route-state-retry="true"
+ data-monitor-editor-route-state-retry-owner="hertzbeat-ui-button"
+ >
+ {t('monitor.route-state.action.retry')}
+ </HzButton>
+ </div>
+ </section>
+ )}
+ >
+ {data => (
+ <MonitorEditorSurface
+ initial={data}
+ mode="edit"
+ returnContext={returnContext}
+ />
+ )}
+ </ClientWorkbench>
+ </div>
+ );
+}
diff --git a/web-next/app/monitors/[monitorId]/edit/page.test.tsx b/web-next/app/monitors/[monitorId]/edit/page.test.tsx
index a34af5e414..66af81e4b5 100644
--- a/web-next/app/monitors/[monitorId]/edit/page.test.tsx
+++ b/web-next/app/monitors/[monitorId]/edit/page.test.tsx
@@ -1,11 +1,13 @@
import React from 'react';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import MonitorEditPage from './page';
+import MonitorEditPage from './monitor-edit-page';
import { createTranslatorMock } from '../../../../test/i18n-test-helper';
+import type { MonitorEditRouteState } from '../../../../lib/monitor-editor/query-state';
const mockState = vi.hoisted(() => ({
- searchParams: new URLSearchParams(),
lastLoad: null as null | (() => Promise<unknown>),
routerPush: vi.fn(),
draft: {
@@ -33,35 +35,39 @@ const mockState = vi.hoisted(() => ({
}
}));
-const loadMonitorEditorDraft = vi.hoisted(() => vi.fn(async () => mockState.draft));
+const loadMonitorEditorDraftFromFacade = vi.hoisted(() => vi.fn(async () => mockState.draft));
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockState.routerPush
- }),
- useSearchParams: () =>
- ({
- get: (key: string) => mockState.searchParams.get(key)
- }) as { get(name: string): string | null }
+ })
}));
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
- t: createTranslatorMock(),
- locale: 'en-US'
+ t: createTranslatorMock({ locale: 'zh-CN' }),
+ locale: 'zh-CN'
})
}));
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
- load
+ load,
+ cacheKey,
+ cacheSettledTtlMs
}: {
children: (data: any) => React.ReactNode;
load: () => Promise<unknown>;
+ cacheKey?: string;
+ cacheSettledTtlMs?: number;
}) => {
mockState.lastLoad = load;
- return <div data-client-workbench="true">{children(mockState.draft)}</div>;
+ return (
+ <div data-client-workbench="true" data-cache-key={cacheKey} data-cache-settled-ttl={cacheSettledTtlMs}>
+ {children(mockState.draft)}
+ </div>
+ );
}
}));
@@ -118,10 +124,23 @@ vi.mock('@/lib/api-client', () => ({
apiMessagePut: vi.fn()
}));
+vi.mock('@/lib/monitor-api-facade', () => ({
+ api: {
+ monitors: {
+ editorDetail: vi.fn(),
+ editorCollectors: vi.fn(),
+ editorParamDefines: vi.fn()
+ }
+ }
+}));
+
vi.mock('@/lib/monitor-editor/controller', () => ({
+ buildMonitorEditCacheKey: (monitorId: string) => `monitor-editor-edit:/monitor/${monitorId}:/collector`,
+ buildMonitorEditorCollectorsUrl: () => '/collector',
+ buildMonitorEditorMonitorUrl: (monitorId: string) => `/monitor/${monitorId}`,
createMonitor: vi.fn(),
detectMonitor: vi.fn(),
- loadMonitorEditorDraft,
+ loadMonitorEditorDraftFromFacade,
loadMonitorScrapeDraft: vi.fn(async () => ({
scrapeParams: [],
scrapeParamDefines: []
@@ -136,6 +155,7 @@ vi.mock('@/lib/monitor-editor/controller', () => ({
}));
vi.mock('@/lib/monitor-editor/navigation', () => ({
+ buildMonitorEditorCancelUrl: () => '/monitors',
buildMonitorEditorReturnUrl: () => '/monitors?app=website'
}));
@@ -170,29 +190,121 @@ vi.mock('@/lib/utils', () => ({
cn: (...values: Array<string | undefined | null | false>) => values.filter(Boolean).join(' ')
}));
+function renderMonitorEditPage(
+ initialRouteState: MonitorEditRouteState = {
+ returnContext: { returnTo: '/entities/7' }
+ }
+) {
+ return renderToStaticMarkup(<MonitorEditPage monitorId="42" initialRouteState={initialRouteState} />);
+}
+
describe('MonitorEditPage', () => {
beforeEach(() => {
- mockState.searchParams = new URLSearchParams('returnTo=%2Fentities%2F7');
mockState.lastLoad = null;
mockState.routerPush.mockReset();
- loadMonitorEditorDraft.mockClear().mockResolvedValue(mockState.draft);
+ loadMonitorEditorDraftFromFacade.mockClear().mockResolvedValue(mockState.draft);
});
it('renders the HertzBeat monitor form surface for the edit route', () => {
- const html = renderToStaticMarkup(<MonitorEditPage params={Promise.resolve({ monitorId: '42' })} />);
+ const html = renderMonitorEditPage();
expect(html).toContain('<form');
+ expect(html).toContain('data-cache-key="monitor-editor-edit:/monitor/42:/collector"');
+ expect(html).toContain('data-cache-settled-ttl="10000"');
+ expect(html).toContain('data-monitor-editor-app-source-contract="angular-route-context-hidden-field"');
+ expect(html).toContain('data-monitor-editor-static-host-position-contract="angular-before-name"');
+ expect(html).toContain('data-monitor-editor-field-order-contract="angular-monitor-form-sequence"');
+ expect(html).toContain('data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"');
+ expect(html).toContain('data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"');
+ expect(html).toContain('data-monitor-editor-payload-param-merge-contract="angular-params-advanced-sdparams"');
+ expect(html).toContain('data-monitor-editor-payload-host-instance-contract="angular-host-param-as-instance"');
+ expect(html).toContain('data-monitor-editor-service-discovery-params="angular-nonstatic-only"');
+ expect(html).toContain('data-monitor-editor-ssl-port-notice="angular-info-notification"');
+ expect(html).toContain('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"');
+ expect(html).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
expect(html).toContain('checkout');
- expect(html).toContain('OK');
+ expect(html).toContain('确定');
});
it('loads the edit-monitor draft with the current monitor id', async () => {
- renderToStaticMarkup(<MonitorEditPage params={Promise.resolve({ monitorId: '42' })} />);
+ renderMonitorEditPage();
await mockState.lastLoad?.();
- expect(loadMonitorEditorDraft).toHaveBeenCalledWith(expect.any(Function), 'edit', {
- monitorId: '42'
- });
+ expect(loadMonitorEditorDraftFromFacade).toHaveBeenCalledWith(
+ expect.objectContaining({
+ readMonitorDetail: expect.any(Function),
+ readCollectors: expect.any(Function),
+ readParamDefines: expect.any(Function)
+ }),
+ 'edit',
+ {
+ monitorId: '42'
+ }
+ );
+ });
+
+ it('accepts typed return context from the edit route wrapper without changing the draft loader', async () => {
+ const initialRouteState: MonitorEditRouteState = {
+ returnContext: {
+ labels: 'team=platform',
+ entityId: '7',
+ entityName: 'Checkout',
+ timeRange: '1h',
+ returnTo: '/entities/7'
+ }
+ };
+ const html = renderMonitorEditPage(initialRouteState);
+
+ expect(html).toContain('data-cache-key="monitor-editor-edit:/monitor/42:/collector"');
+
+ await mockState.lastLoad?.();
+
+ expect(loadMonitorEditorDraftFromFacade).toHaveBeenCalledWith(
+ expect.objectContaining({
+ readMonitorDetail: expect.any(Function),
+ readCollectors: expect.any(Function),
+ readParamDefines: expect.any(Function)
+ }),
+ 'edit',
+ {
+ monitorId: '42'
+ }
+ );
+ });
+
+ it('loads monitor editor draft through the monitor domain facade instead of a raw getter', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/monitors/[monitorId]/edit/monitor-edit-page.tsx'), 'utf8');
+
+ expect(source).toContain('loadMonitorEditorDraftFromFacade(');
+ expect(source).toContain('readMonitorDetail: api.monitors.editorDetail');
+ expect(source).toContain('readCollectors: api.monitors.editorCollectors');
+ expect(source).toContain('readParamDefines: api.monitors.editorParamDefines');
+ expect(source).not.toContain("import { apiMessageGet } from '@/lib/api-client'");
+ expect(source).not.toContain('loadMonitorEditorDraft(apiMessageGet');
+ });
+
+ it('keeps edit load failures in the Angular-style monitor form route-state surface', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/monitors/[monitorId]/edit/monitor-edit-page.tsx'), 'utf8');
+
+ expect(source).toContain('renderError={(message, retry) =>');
+ expect(source).toContain('data-monitor-editor-route-state="error"');
+ expect(source).toContain('data-monitor-editor-edit-error-state="angular-route-state"');
+ expect(source).toContain('data-monitor-editor-route-state-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-monitor-editor-app-source-contract="angular-route-context-hidden-field"');
+ expect(source).toContain('data-monitor-editor-static-host-position-contract="angular-before-name"');
+ expect(source).toContain('data-monitor-editor-field-order-contract="angular-monitor-form-sequence"');
+ expect(source).toContain('data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"');
+ expect(source).toContain('data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"');
+ expect(source).toContain('data-monitor-editor-payload-param-merge-contract="angular-params-advanced-sdparams"');
+ expect(source).toContain('data-monitor-editor-payload-host-instance-contract="angular-host-param-as-instance"');
+ expect(source).toContain('data-monitor-editor-service-discovery-params="angular-nonstatic-only"');
+ expect(source).toContain('data-monitor-editor-ssl-port-notice="angular-info-notification"');
+ expect(source).toContain('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"');
+ expect(source).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(source).toContain("t('monitor.route-state.form.error.title')");
+ expect(source).toContain("t('monitor.route-state.form.error.copy')");
+ expect(source).toContain("t('monitor.route-state.action.retry')");
+ expect(source).toContain('data-monitor-editor-route-state-retry-owner="hertzbeat-ui-button"');
});
});
diff --git a/web-next/app/monitors/[monitorId]/edit/page.tsx b/web-next/app/monitors/[monitorId]/edit/page.tsx
index ad4ded7459..751d6afb7c 100644
--- a/web-next/app/monitors/[monitorId]/edit/page.tsx
+++ b/web-next/app/monitors/[monitorId]/edit/page.tsx
@@ -1,40 +1,17 @@
-'use client';
-
import React from 'react';
-import { useCallback } from 'react';
-import { useSearchParams } from 'next/navigation';
-import { ClientWorkbench } from '@/components/workbench/client-workbench';
-import { MonitorEditorSurface } from '../../../../components/pages/monitor-editor-surface';
-import { useI18n } from '@/components/providers/i18n-provider';
-import { apiMessageGet } from '@/lib/api-client';
-import { loadMonitorEditorDraft } from '@/lib/monitor-editor/controller';
-
-export default function MonitorEditPage({ params }: { params: Promise<{ monitorId: string }> }) {
- const { t } = useI18n();
- const searchParams = useSearchParams();
- const returnTo = searchParams.get('returnTo');
- const load = useCallback(async () => {
- const resolved = await params;
- return loadMonitorEditorDraft(apiMessageGet, 'edit', { monitorId: resolved.monitorId });
- }, [params]);
+import MonitorEditPage from './monitor-edit-page';
+import { readMonitorEditRouteState, type MonitorEditSearchParams } from '../../../../lib/monitor-editor/query-state';
- return (
- <ClientWorkbench load={load} loadingCopy={t('monitor.editor.loading.edit')}>
- {data => (
- <MonitorEditorSurface
- initial={data}
- mode="edit"
- returnContext={{
- labels: searchParams.get('labels'),
- pageIndex: searchParams.get('pageIndex'),
- pageSize: searchParams.get('pageSize'),
- entityId: searchParams.get('entityId'),
- entityName: searchParams.get('entityName'),
- returnTo
- }}
- />
- )}
- </ClientWorkbench>
- );
+export default async function MonitorEditRoutePage({
+ params,
+ searchParams
+}: {
+ params: Promise<{ monitorId: string }>;
+ searchParams?: Promise<MonitorEditSearchParams>;
+}) {
+ const { monitorId } = await params;
+ const resolvedSearchParams = await searchParams;
+ const routeState = readMonitorEditRouteState(resolvedSearchParams);
+ return <MonitorEditPage monitorId={monitorId} initialRouteState={routeState} />;
}
diff --git a/web-next/app/monitors/new/monitor-new-page.tsx b/web-next/app/monitors/new/monitor-new-page.tsx
new file mode 100644
index 0000000000..64bad90869
--- /dev/null
+++ b/web-next/app/monitors/new/monitor-new-page.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import React, { useCallback } from 'react';
+import { ClientWorkbench } from '@/components/workbench/client-workbench';
+import { MonitorEditorSurface } from '../../../components/pages/monitor-editor-surface';
+import { useI18n } from '@/components/providers/i18n-provider';
+import { api } from '@/lib/monitor-api-facade';
+import { buildMonitorEditorCollectorsUrl, buildMonitorEditorParamDefinesUrl, loadMonitorEditorDraftFromFacade } from '@/lib/monitor-editor/controller';
+import type { MonitorNewRouteState } from '@/lib/monitor-editor/query-state';
+
+const MONITOR_NEW_SETTLED_CACHE_TTL_MS = 10_000;
+const EMPTY_MONITOR_NEW_ROUTE_STATE: MonitorNewRouteState = {
+ app: 'website',
+ returnContext: {}
+};
+
+export default function MonitorNewPage({ initialRouteState }: { initialRouteState?: MonitorNewRouteState } = {}) {
+ const { t } = useI18n();
+ const monitorNewRouteState = initialRouteState ?? EMPTY_MONITOR_NEW_ROUTE_STATE;
+ const { app, returnContext } = monitorNewRouteState;
+ const monitorNewCollectorsUrl = React.useMemo(() => buildMonitorEditorCollectorsUrl(), []);
+ const monitorNewParamsUrl = React.useMemo(() => buildMonitorEditorParamDefinesUrl(app), [app]);
+ const monitorNewCacheKey = React.useMemo(
+ () => ['monitor-editor-new', monitorNewCollectorsUrl, monitorNewParamsUrl].join(':'),
+ [monitorNewCollectorsUrl, monitorNewParamsUrl]
+ );
+
+ const load = useCallback(
+ async () =>
+ loadMonitorEditorDraftFromFacade(
+ {
+ readCollectors: api.monitors.editorCollectors,
+ readParamDefines: api.monitors.editorParamDefines
+ },
+ 'new',
+ { app }
+ ),
+ [app]
+ );
+
+ return (
+ <div
+ data-monitor-editor-app-source-contract="angular-route-context-hidden-field"
+ data-monitor-editor-static-host-position-contract="angular-before-name"
+ data-monitor-editor-field-order-contract="angular-monitor-form-sequence"
+ data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"
+ data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"
+ data-monitor-editor-payload-param-merge-contract="angular-params-advanced-sdparams"
+ data-monitor-editor-payload-host-instance-contract="angular-host-param-as-instance"
+ data-monitor-editor-service-discovery-params="angular-nonstatic-only"
+ data-monitor-editor-ssl-port-notice="angular-info-notification"
+ data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"
+ data-monitor-editor-label-selector="angular-app-label-selector"
+ >
+ <ClientWorkbench
+ load={load}
+ loadingCopy={t('monitor.editor.loading.new')}
+ cacheKey={monitorNewCacheKey}
+ cacheSettledTtlMs={MONITOR_NEW_SETTLED_CACHE_TTL_MS}
+ >
+ {data => (
+ <MonitorEditorSurface
+ initial={data}
+ mode="new"
+ returnContext={returnContext}
+ />
+ )}
+ </ClientWorkbench>
+ </div>
+ );
+}
diff --git a/web-next/app/monitors/new/page.test.tsx b/web-next/app/monitors/new/page.test.tsx
index 4ca09c8e81..f90bf7fe33 100644
--- a/web-next/app/monitors/new/page.test.tsx
+++ b/web-next/app/monitors/new/page.test.tsx
@@ -1,13 +1,18 @@
import React from 'react';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import MonitorNewPage from './page';
+import MonitorNewPage from './monitor-new-page';
import { createTranslatorMock } from '../../../test/i18n-test-helper';
+import type { MonitorNewRouteState } from '../../../lib/monitor-editor/query-state';
const mockState = vi.hoisted(() => ({
- searchParams: new URLSearchParams(),
lastLoad: null as null | (() => Promise<unknown>),
routerPush: vi.fn(),
+ redirect: vi.fn((target: string) => {
+ throw new Error(`redirect:${target}`);
+ }),
draft: {
monitor: {
id: 0,
@@ -33,35 +38,40 @@ const mockState = vi.hoisted(() => ({
}
}));
-const loadMonitorEditorDraft = vi.hoisted(() => vi.fn(async () => mockState.draft));
+const loadMonitorEditorDraftFromFacade = vi.hoisted(() => vi.fn(async () => mockState.draft));
vi.mock('next/navigation', () => ({
+ redirect: mockState.redirect,
useRouter: () => ({
push: mockState.routerPush
- }),
- useSearchParams: () =>
- ({
- get: (key: string) => mockState.searchParams.get(key)
- }) as { get(name: string): string | null }
+ })
}));
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
- t: createTranslatorMock(),
- locale: 'en-US'
+ t: createTranslatorMock({ locale: 'zh-CN' }),
+ locale: 'zh-CN'
})
}));
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
- load
+ load,
+ cacheKey,
+ cacheSettledTtlMs
}: {
children: (data: any) => React.ReactNode;
load: () => Promise<unknown>;
+ cacheKey?: string;
+ cacheSettledTtlMs?: number;
}) => {
mockState.lastLoad = load;
- return <div data-client-workbench="true">{children(mockState.draft)}</div>;
+ return (
+ <div data-client-workbench="true" data-cache-key={cacheKey} data-cache-settled-ttl={cacheSettledTtlMs}>
+ {children(mockState.draft)}
+ </div>
+ );
}
}));
@@ -118,10 +128,21 @@ vi.mock('@/lib/api-client', () => ({
apiMessagePut: vi.fn()
}));
+vi.mock('@/lib/monitor-api-facade', () => ({
+ api: {
+ monitors: {
+ editorCollectors: vi.fn(),
+ editorParamDefines: vi.fn()
+ }
+ }
+}));
+
vi.mock('@/lib/monitor-editor/controller', () => ({
+ buildMonitorEditorCollectorsUrl: () => '/collector',
+ buildMonitorEditorParamDefinesUrl: (app: string) => `/apps/${app}/params`,
createMonitor: vi.fn(),
detectMonitor: vi.fn(),
- loadMonitorEditorDraft,
+ loadMonitorEditorDraftFromFacade,
loadMonitorScrapeDraft: vi.fn(async () => ({
scrapeParams: [],
scrapeParamDefines: []
@@ -136,6 +157,7 @@ vi.mock('@/lib/monitor-editor/controller', () => ({
}));
vi.mock('@/lib/monitor-editor/navigation', () => ({
+ buildMonitorEditorCancelUrl: () => '/monitors',
buildMonitorEditorReturnUrl: () => '/monitors?app=website'
}));
@@ -170,29 +192,121 @@ vi.mock('@/lib/utils', () => ({
cn: (...values: Array<string | undefined | null | false>) => values.filter(Boolean).join(' ')
}));
+function renderMonitorNewPage(
+ initialRouteState: MonitorNewRouteState = {
+ app: 'website',
+ returnContext: { returnTo: '/entities/7' }
+ }
+) {
+ return renderToStaticMarkup(<MonitorNewPage initialRouteState={initialRouteState} />);
+}
+
describe('MonitorNewPage', () => {
beforeEach(() => {
- mockState.searchParams = new URLSearchParams('app=website&returnTo=%2Fentities%2F7');
mockState.lastLoad = null;
mockState.routerPush.mockReset();
- loadMonitorEditorDraft.mockClear().mockResolvedValue(mockState.draft);
+ mockState.redirect.mockClear();
+ loadMonitorEditorDraftFromFacade.mockClear().mockResolvedValue(mockState.draft);
});
it('renders the HertzBeat monitor form surface for the new route', () => {
- const html = renderToStaticMarkup(<MonitorNewPage />);
+ const html = renderMonitorNewPage();
expect(html).toContain('<form');
- expect(html).toContain('New monitor');
- expect(html).toContain('OK');
+ expect(html).toContain('data-cache-key="monitor-editor-new:/collector:/apps/website/params"');
+ expect(html).toContain('data-cache-settled-ttl="10000"');
+ expect(html).toContain('data-monitor-editor-app-source-contract="angular-route-context-hidden-field"');
+ expect(html).toContain('data-monitor-editor-static-host-position-contract="angular-before-name"');
+ expect(html).toContain('data-monitor-editor-field-order-contract="angular-monitor-form-sequence"');
+ expect(html).toContain('data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"');
+ expect(html).toContain('data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"');
+ expect(html).toContain('data-monitor-editor-payload-param-merge-contract="angular-params-advanced-sdparams"');
+ expect(html).toContain('data-monitor-editor-payload-host-instance-contract="angular-host-param-as-instance"');
+ expect(html).toContain('data-monitor-editor-service-discovery-params="angular-nonstatic-only"');
+ expect(html).toContain('data-monitor-editor-ssl-port-notice="angular-info-notification"');
+ expect(html).toContain('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"');
+ expect(html).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(html).toContain('新建监控');
+ expect(html).toContain('确定');
});
it('loads the new-monitor draft with the current app context', async () => {
- renderToStaticMarkup(<MonitorNewPage />);
+ renderMonitorNewPage();
+
+ await mockState.lastLoad?.();
+
+ expect(loadMonitorEditorDraftFromFacade).toHaveBeenCalledWith(
+ expect.objectContaining({
+ readCollectors: expect.any(Function),
+ readParamDefines: expect.any(Function)
+ }),
+ 'new',
+ {
+ app: 'website'
+ }
+ );
+ });
+
+ it('uses the typed route state app when loading a new monitor from a handoff', async () => {
+ const initialRouteState: MonitorNewRouteState = {
+ app: 'prometheus',
+ returnContext: {
+ labels: 'team=platform',
+ entityId: '42',
+ entityName: 'Checkout Service',
+ timeRange: '1h',
+ returnTo: '/entities/42'
+ }
+ };
+ const html = renderMonitorNewPage(initialRouteState);
+
+ expect(html).toContain('data-cache-key="monitor-editor-new:/collector:/apps/prometheus/params"');
await mockState.lastLoad?.();
- expect(loadMonitorEditorDraft).toHaveBeenCalledWith(expect.any(Function), 'new', {
- app: 'website'
- });
+ expect(loadMonitorEditorDraftFromFacade).toHaveBeenCalledWith(
+ expect.objectContaining({
+ readCollectors: expect.any(Function),
+ readParamDefines: expect.any(Function)
+ }),
+ 'new',
+ {
+ app: 'prometheus'
+ }
+ );
+ });
+
+ it('loads monitor editor draft through the monitor domain facade instead of a raw getter', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/monitors/new/monitor-new-page.tsx'), 'utf8');
+
+ expect(source).toContain('loadMonitorEditorDraftFromFacade(');
+ expect(source).toContain('readCollectors: api.monitors.editorCollectors');
+ expect(source).toContain('readParamDefines: api.monitors.editorParamDefines');
+ expect(source).not.toContain("import { apiMessageGet } from '@/lib/api-client'");
+ expect(source).not.toContain('loadMonitorEditorDraft(apiMessageGet');
+ });
+});
+
+describe('MonitorNewRoutePage', () => {
+ beforeEach(() => {
+ mockState.redirect.mockClear();
+ });
+
+ it('redirects missing app query to the Angular-style website monitor setup URL', async () => {
+ const { default: MonitorNewRoutePage } = await import('./page');
+
+ await expect(MonitorNewRoutePage({ searchParams: Promise.resolve({}) })).rejects.toThrow(
+ 'redirect:/monitors/new?app=website'
+ );
+ expect(mockState.redirect).toHaveBeenCalledWith('/monitors/new?app=website');
+ });
+
+ it('does not redirect when the app query is present', async () => {
+ const { default: MonitorNewRoutePage } = await import('./page');
+ const element = await MonitorNewRoutePage({ searchParams: Promise.resolve({ app: 'prometheus' }) });
+ const html = renderToStaticMarkup(element);
+
+ expect(mockState.redirect).not.toHaveBeenCalled();
+ expect(html).toContain('data-cache-key="monitor-editor-new:/collector:/apps/prometheus/params"');
});
});
diff --git a/web-next/app/monitors/new/page.tsx b/web-next/app/monitors/new/page.tsx
index 2f346bead3..70d8c3dd43 100644
--- a/web-next/app/monitors/new/page.tsx
+++ b/web-next/app/monitors/new/page.tsx
@@ -1,38 +1,23 @@
-'use client';
-
import React from 'react';
-import { useCallback } from 'react';
-import { useSearchParams } from 'next/navigation';
-import { ClientWorkbench } from '@/components/workbench/client-workbench';
-import { MonitorEditorSurface } from '../../../components/pages/monitor-editor-surface';
-import { useI18n } from '@/components/providers/i18n-provider';
-import { apiMessageGet } from '@/lib/api-client';
-import { loadMonitorEditorDraft } from '@/lib/monitor-editor/controller';
-
-export default function MonitorNewPage() {
- const { t } = useI18n();
- const searchParams = useSearchParams();
- const app = searchParams.get('app') || 'website';
- const returnTo = searchParams.get('returnTo');
+import { redirect } from 'next/navigation';
- const load = useCallback(async () => loadMonitorEditorDraft(apiMessageGet, 'new', { app }), [app]);
+import MonitorNewPage from './monitor-new-page';
+import {
+ buildMonitorNewDefaultAppRedirectUrl,
+ hasMonitorNewAppParam,
+ readMonitorNewRouteState,
+ type MonitorNewSearchParams
+} from '../../../lib/monitor-editor/query-state';
- return (
- <ClientWorkbench load={load} loadingCopy={t('monitor.editor.loading.new')}>
- {data => (
- <MonitorEditorSurface
- initial={data}
- mode="new"
- returnContext={{
- labels: searchParams.get('labels'),
- pageIndex: searchParams.get('pageIndex'),
- pageSize: searchParams.get('pageSize'),
- entityId: searchParams.get('entityId'),
- entityName: searchParams.get('entityName'),
- returnTo
- }}
- />
- )}
- </ClientWorkbench>
- );
+export default async function MonitorNewRoutePage({
+ searchParams
+}: {
+ searchParams?: Promise<MonitorNewSearchParams>;
+} = {}) {
+ const resolvedSearchParams = await searchParams;
+ if (!hasMonitorNewAppParam(resolvedSearchParams)) {
+ redirect(buildMonitorNewDefaultAppRedirectUrl());
+ }
+ const routeState = readMonitorNewRouteState(resolvedSearchParams);
+ return <MonitorNewPage initialRouteState={routeState} />;
}
diff --git a/web-next/components/pages/monitor-editor-surface.test.tsx b/web-next/components/pages/monitor-editor-surface.test.tsx
index fc2f56f3b4..9537e8f561 100644
--- a/web-next/components/pages/monitor-editor-surface.test.tsx
+++ b/web-next/components/pages/monitor-editor-surface.test.tsx
@@ -6,6 +6,10 @@ import { describe, expect, it, vi } from 'vitest';
import { MonitorEditorSurface } from './monitor-editor-surface';
import { createTranslatorMock } from '../../test/i18n-test-helper';
+const i18nMockState = vi.hoisted(() => ({
+ locale: 'en-US' as 'en-US' | 'zh-CN'
+}));
+
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn()
@@ -14,11 +18,48 @@ vi.mock('next/navigation', () => ({
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
- t: createTranslatorMock(),
- locale: 'en-US'
+ t: createTranslatorMock({ locale: i18nMockState.locale }),
+ locale: i18nMockState.locale
})
}));
+vi.mock('@uiw/react-codemirror', () => ({
+ default: ({ value, readOnly, extensions, height, minHeight, basicSetup, theme, onChange, ...props }: any) => (
+ <div
+ data-testid={props['data-testid']}
+ data-mocked-codemirror="true"
+ data-readonly={readOnly ? 'true' : 'false'}
+ data-height={height}
+ data-min-height={minHeight}
+ data-basic-setup={basicSetup ? 'true' : 'false'}
+ data-theme={theme ? 'custom-dark' : 'missing'}
+ data-extension-count={Array.isArray(extensions) ? String(extensions.length) : '0'}
+ onClick={() => onChange?.(`${value}\nnext`)}
+ >
+ {value}
+ </div>
+ )
+}));
+
+vi.mock('@codemirror/lang-yaml', () => ({ yaml: () => 'yaml-extension' }));
+vi.mock('@codemirror/lang-json', () => ({ json: () => 'json-extension' }));
+vi.mock('@codemirror/lang-html', () => ({ html: () => 'html-extension' }));
+vi.mock('@codemirror/lang-javascript', () => ({ javascript: () => 'javascript-extension' }));
+vi.mock('@codemirror/view', () => ({
+ EditorView: {
+ lineWrapping: 'line-wrapping-extension',
+ theme: () => 'theme-extension'
+ }
+}));
+vi.mock('@codemirror/state', () => ({
+ EditorState: {
+ readOnly: {
+ of: () => 'readonly-extension'
+ }
+ }
+}));
+vi.mock('@codemirror/theme-one-dark', () => ({ oneDark: 'one-dark-theme' }));
+
vi.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
buttonVariants: () => ''
@@ -73,11 +114,18 @@ vi.mock('@/components/workbench/primitives', () => ({
}));
vi.mock('@/components/workbench/workbench-page', () => ({
- WorkbenchPage: ({ kicker, title, subtitle, actions, main, side }: any) => (
+ WorkbenchPage: ({ kicker, title, subtitle, facts, actions, main, side }: any) => (
<main>
<div>{kicker}</div>
<h1>{title}</h1>
<p>{subtitle}</p>
+ <div>
+ {(facts || []).map((fact: any, index: number) => (
+ <div key={`${fact.label}-${index}`} data-monitor-editor-fact={fact.label} data-monitor-editor-fact-value={fact.value}>
+ {`${fact.label}:${fact.value}`}
+ </div>
+ ))}
+ </div>
<div>{actions}</div>
<div>{main}</div>
<aside>{side}</aside>
@@ -86,7 +134,14 @@ vi.mock('@/components/workbench/workbench-page', () => ({
RowList: ({ rows }: any) => (
<div>
{rows.map((row: any, index: number) => (
- <div key={`${row.title}-${index}`}>{`${row.title}:${row.copy}`}</div>
+ <div
+ key={`${row.title}-${index}`}
+ data-monitor-editor-payload-row={row.title}
+ data-monitor-editor-payload-copy={row.copy}
+ data-monitor-editor-payload-meta={row.meta}
+ >
+ {`${row.title}:${row.copy}:${row.meta}`}
+ </div>
))}
</div>
)
@@ -98,12 +153,26 @@ vi.mock('@/lib/api-client', () => ({
apiMessagePut: vi.fn()
}));
+vi.mock('@/lib/monitor-api-facade', () => ({
+ api: {
+ monitors: {
+ create: vi.fn(),
+ detect: vi.fn(),
+ editorParamDefines: vi.fn(),
+ update: vi.fn()
+ }
+ }
+}));
+
vi.mock('@/lib/monitor-editor/controller', () => ({
- createMonitor: vi.fn(),
- detectMonitor: vi.fn(),
- loadMonitorScrapeDraft: vi.fn(),
+ applyMonitorHostNameAutofill: (draft: any) => draft,
+ createMonitorFromFacade: vi.fn(),
+ detectMonitorFromFacade: vi.fn(),
+ loadMonitorScrapeDraftFromFacade: vi.fn(async () => ({ scrapeParams: [], scrapeParamDefines: [] })),
+ shouldPreserveMonitorScrapeParamsForLoad: (previousScrape: string | null | undefined, nextScrape: string) =>
+ !previousScrape && nextScrape !== 'static',
syncMonitorDependentDisplay: (draft: any) => draft,
- updateMonitor: vi.fn(),
+ updateMonitorFromFacade: vi.fn(),
updateMonitorEditorParam: (draft: any, kind: string, index: number, value: unknown) => ({
...draft,
[kind]: draft[kind].map((row: any, rowIndex: number) => (rowIndex === index ? { ...row, paramValue: value } : row))
@@ -112,6 +181,7 @@ vi.mock('@/lib/monitor-editor/controller', () => ({
}));
vi.mock('@/lib/monitor-editor/navigation', () => ({
+ buildMonitorEditorCancelUrl: () => '/monitors',
buildMonitorEditorReturnUrl: () => '/monitors?app=website'
}));
@@ -180,6 +250,17 @@ describe('MonitorEditorSurface', () => {
expect(html).toContain('Add label');
expect(html).toContain('Add annotation');
+ expect(html).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(html).toContain('data-monitor-editor-label-selector-owner="cold-label-selector"');
+ expect(html).toContain('data-cold-label-selector-owner="cold-label-selector"');
+ expect(html).toContain('data-cold-label-selector-record-row="team:platform"');
+ expect(html).toContain('data-cold-label-selector-key-input="searchable-key"');
+ expect(html).toContain('data-cold-label-selector-value-input="searchable-value"');
+ expect(html).toContain('data-hz-ui="key-value-editor"');
+ expect(html).toContain('data-monitor-editor-key-value-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-hz-key-value-action="add"');
+ expect(html).toContain('data-hz-key-value-footer="action-row"');
+ expect(html).toContain('data-hz-key-value-action-layout="footer-command"');
expect(html).toContain('<form');
expect(html).toContain('team');
expect(html).toContain('platform');
@@ -189,6 +270,216 @@ describe('MonitorEditorSurface', () => {
expect(html).not.toContain('JSON object for monitor annotations.');
});
+ it('renders monitor editor label and annotation actions in zh-CN', () => {
+ i18nMockState.locale = 'zh-CN';
+ try {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'checkout',
+ instance: 'example.com:80',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: { team: 'platform' },
+ annotations: { owner: 'sre' }
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: 'example.com' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=website' }}
+ />
+ );
+
+ expect(html).toContain('添加标签');
+ expect(html).toContain('添加注解');
+ expect(html).not.toContain('Add label');
+ expect(html).not.toContain('Add annotation');
+ } finally {
+ i18nMockState.locale = 'en-US';
+ }
+ });
+
+ it('renders the system default collector option instead of treating it as none', () => {
+ i18nMockState.locale = 'zh-CN';
+ try {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'mysql',
+ name: 'mysql-prod',
+ instance: '127.0.0.1:3306',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 10,
+ status: 2,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: '127.0.0.1' }],
+ paramDefines: [{ field: 'host', type: 'text', name: '目标Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: ['edge-a']
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=mysql' }}
+ />
+ );
+
+ expect(html).toContain('默认系统调度');
+ expect(html).toContain('data-monitor-editor-collector-selection="angular-collectors-selection-tags"');
+ expect(html).toContain('data-monitor-editor-collector-tags="angular-status-ip-mode-tags"');
+ expect(html).toContain('data-monitor-editor-collector-tag="system-default"');
+ expect(html).not.toContain('<option value="" selected="">无</option>');
+ } finally {
+ i18nMockState.locale = 'en-US';
+ }
+ });
+
+ it('preserves Angular collector option status, ip, and mode context', () => {
+ i18nMockState.locale = 'zh-CN';
+ try {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'mysql',
+ name: 'mysql-prod',
+ instance: '127.0.0.1:3306',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 10,
+ status: 2,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: 'edge-a',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: '127.0.0.1' }],
+ paramDefines: [{ field: 'host', type: 'text', name: '目标Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: [{ name: 'edge-a', ip: '10.0.0.12', status: 0, mode: 'private' }]
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=mysql' }}
+ />
+ );
+
+ expect(html).toContain('edge-a · 10.0.0.12');
+ expect(html).toContain('data-monitor-editor-collector-selection-owner="hertzbeat-ui-select"');
+ expect(html).toContain('data-monitor-editor-collector-tags="angular-status-ip-mode-tags"');
+ expect(html).toContain('data-monitor-editor-collector-tag="status"');
+ expect(html).toContain('data-monitor-editor-collector-tag="ip"');
+ expect(html).toContain('data-monitor-editor-collector-tag="mode"');
+ expect(html).toContain('10.0.0.12');
+ } finally {
+ i18nMockState.locale = 'en-US';
+ }
+ });
+
+ it('renders monitor editor as a linear Angular-style form instead of a split workbench', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'prometheus',
+ name: '',
+ instance: '',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: '127.0.0.1' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [{ field: 'headers', paramValue: '{}' }],
+ advancedParamDefines: [{ field: 'headers', type: 'key-value', name: 'Headers' }],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="new"
+ returnContext={{ returnTo: '/monitors' }}
+ />
+ );
+
+ const baseIndex = html.indexOf('Basic settings');
+ const paramsIndex = html.indexOf('Runtime parameters');
+ const advancedIndex = html.indexOf('Advanced');
+ const runtimeIndex = html.indexOf('Collection schedule');
+ const labelsIndex = html.indexOf('Labels');
+ const annotationsIndex = html.indexOf('Annotations');
+ const descriptionIndex = html.indexOf('Description');
+ const actionsIndex = html.indexOf('data-monitor-editor-action-dock="bottom"');
+
+ expect(html).toContain('data-monitor-editor-layout="linear"');
+ expect(html).toContain('data-hz-ui="monitor-editor-form"');
+ expect(html).toContain('data-monitor-editor-form-owner="hertzbeat-ui-monitor-editor-form"');
+ expect(html).toContain('data-monitor-editor-linear-shell="true"');
+ expect(html).toContain('data-hz-ui="monitor-editor-header"');
+ expect(html).toContain('data-monitor-editor-linear-header="hertzbeat-ui-monitor-editor-header"');
+ expect(html).toContain('data-hz-ui="monitor-editor-field-grid"');
+ expect(html).toContain('data-monitor-editor-field-grid-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect((html.match(/data-hz-ui="monitor-editor-field-grid"/g) ?? []).length).toBe(3);
+ expect(html).toContain('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"');
+ expect(html).toContain('data-monitor-editor-advanced-visible-contract="angular-visible-param-only"');
+ expect(html).toContain('data-monitor-editor-advanced-collapse-state="collapsed"');
+ expect(html).toContain('data-monitor-editor-advanced-toggle="angular-dashed-collapse-trigger"');
+ expect(html).toContain('data-monitor-editor-detect-cron-validation="angular-detect-skips-cron-format"');
+ expect(html).toContain('data-monitor-editor-cron-required="angular-required-before-detect-save"');
+ expect(html).not.toContain('data-monitor-editor-advanced-fields="expanded"');
+ expect(baseIndex).toBeGreaterThan(-1);
+ expect(baseIndex).toBeLessThan(paramsIndex);
+ expect(paramsIndex).toBeLessThan(advancedIndex);
+ expect(advancedIndex).toBeLessThan(runtimeIndex);
+ expect(runtimeIndex).toBeLessThan(labelsIndex);
+ expect(labelsIndex).toBeLessThan(annotationsIndex);
+ expect(annotationsIndex).toBeLessThan(descriptionIndex);
+ expect(descriptionIndex).toBeLessThan(actionsIndex);
+ expect(html).not.toContain('data-monitor-editor-fact=');
+ expect(html).not.toContain('data-monitor-editor-payload-row=');
+ expect(html).not.toContain('Monitor Configuration');
+ expect(source).not.toContain('WorkbenchPage');
+ expect(source).not.toContain('RowList');
+ expect(source).toContain('HzMonitorEditorForm');
+ expect(source).toContain('HzMonitorEditorHeader');
+ expect(source).toContain('HzMonitorEditorFieldGrid');
+ expect(source).toContain('validateBeforeSubmit(nextDraft, { validateCronFormat: false })');
+ expect(source).not.toContain('className="mx-auto w-full max-w-[980px] space-y-3"');
+ expect(source).not.toContain('className="grid gap-3 md:grid-cols-2"');
+ expect(source).not.toContain('<div className="space-y-3" data-monitor-editor-linear-shell="true">');
+ expect(source).not.toContain('<header\n className="border-b border-[var(--hz-ui-line-soft)] px-3 pb-3 pt-2"');
+ });
+
it('keeps the embedded editor action posture inside one form', () => {
const html = renderToStaticMarkup(
<MonitorEditorSurface
@@ -232,7 +523,490 @@ describe('MonitorEditorSurface', () => {
expect(html).toContain('data-monitor-editor-cancel-action="true"');
});
- it('uses the shared cold number stepper for interval schedules instead of native number input', () => {
+ it('uses the shared mutation bar for monitor editor actions and feedback instead of page-local action chrome', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'checkout',
+ instance: 'example.com:80',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: 'example.com' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=website' }}
+ />
+ );
+
+ expect(html).toContain('data-hz-ui="mutation-bar"');
+ expect(html).toContain('data-hz-ui="monitor-editor-action-bar"');
+ expect(html).toContain('data-hz-ui="monitor-editor-section"');
+ expect(html).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(html).toContain('data-monitor-editor-label-selector-owner="cold-label-selector"');
+ expect(html).toContain('data-monitor-editor-key-value="annotations"');
+ expect(html).toContain('data-monitor-editor-section-owner="hertzbeat-ui-editor-section"');
+ expect(html).toContain('data-monitor-editor-field-owner="hertzbeat-ui-field"');
+ expect(html).toContain('data-hz-ui="field"');
+ expect(html).toContain('data-hz-ui="input"');
+ expect(html).toContain('data-monitor-editor-input-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-monitor-editor-app-source="angular-route-context-hidden-field"');
+ expect(html).toContain('data-monitor-editor-hidden-app="route-or-detail-context"');
+ expect(html).not.toContain('data-monitor-editor-input="app"');
+ expect(html).toContain('data-monitor-editor-field-order="angular-monitor-form-sequence"');
+ expect(html).toContain('data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"');
+ expect(html).toContain('data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"');
+ expect(html).toContain('data-monitor-editor-payload-param-merge="angular-params-advanced-sdparams"');
+ expect(html).toContain('data-monitor-editor-payload-host-instance="angular-host-param-as-instance"');
+ expect(html).toContain('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"');
+ expect(html).toContain('data-monitor-editor-advanced-visible-contract="angular-visible-param-only"');
+ expect(html).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(html).toContain('data-monitor-editor-static-host-position="angular-before-name"');
+ expect(html).toContain('data-monitor-editor-static-host-field="angular-before-name"');
+ expect(html).toContain('data-monitor-editor-input="name"');
+ expect(html).toContain('data-monitor-param-input="host"');
+ expect(html.indexOf('data-monitor-editor-static-host-field="angular-before-name"')).toBeLessThan(
+ html.indexOf('data-monitor-editor-input="name"')
+ );
+ expect(html).toContain('data-monitor-editor-host-name-autofill-contract="angular-new-host-change"');
+ expect(html).not.toContain('data-monitor-editor-host-name-autofill-target="monitor-name"');
+ expect(html).toContain('data-hz-ui="select"');
+ expect(html).toContain('data-monitor-editor-select-owner="hertzbeat-ui-select"');
+ expect(html).toContain('data-monitor-editor-select="scrape"');
+ expect(html).toContain('data-monitor-editor-select="collector"');
+ expect(html).toContain('data-monitor-editor-select="schedule-type"');
+ expect(html).toContain('data-hz-mutation-status="clean"');
+ expect(html).toContain('data-hz-mutation-issue-count="0"');
+ expect(html).toContain('data-monitor-editor-action-dock="bottom"');
+ expect(html).toContain('data-monitor-editor-mutation-bar="true"');
+ expect(html).toContain('data-monitor-editor-action-bar-owner="hertzbeat-ui-monitor-editor-action-bar"');
+ expect(html).toContain('data-monitor-editor-action-bar-layout="centered-footer"');
+ expect(html).toContain('data-monitor-editor-action="detect"');
+ expect(html).toContain('data-monitor-editor-action="submit"');
+ expect(html).toContain('data-monitor-editor-action="cancel"');
+ expect(html).toContain('data-monitor-editor-action-owner="hertzbeat-ui-button"');
+ expect(html).toContain('data-monitor-editor-detect-action="true"');
+ expect(html).toContain('data-monitor-editor-submit-action="true"');
+ expect(html).toContain('data-monitor-editor-detect-busy-label="Available Detecting"');
+ expect(html).toContain('data-monitor-editor-submit-busy-label="Loading..."');
+ expect(html).toContain('data-monitor-editor-save-return="safe-return-context-or-angular-app-list"');
+ expect(html).toContain('data-monitor-editor-save-return-target="/monitors?app=website"');
+ expect(html).toContain('data-monitor-editor-save-notification-contract="angular-success-before-return"');
+ expect(html).toContain('data-monitor-editor-cancel-action="true"');
+ expect(html).toContain('data-monitor-editor-cancel-return="safe-return-context-or-list-root"');
+ expect(html).toContain('data-monitor-editor-cancel-return-target="/monitors"');
+ expect(html.indexOf('data-monitor-editor-section-owner="hertzbeat-ui-editor-section"')).toBeLessThan(
+ html.indexOf('data-monitor-editor-action-dock="bottom"')
+ );
+ expect(source).toContain("from '@hertzbeat/ui'");
+ expect(source).toContain('HzMonitorEditorActionBar');
+ expect(source).toContain('buildMonitorEditorCancelUrl');
+ expect(source).not.toContain('HzMutationBar');
+ expect(source).not.toContain('data-monitor-editor-action-owner="hertzbeat-ui-button"');
+ expect(source).toContain('HzInlineFeedback');
+ expect(source).toContain('variant="embedded"');
+ expect(source).toContain('data-monitor-editor-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain("useState<'idle' | 'detecting' | 'saving'>('idle')");
+ expect(source).toContain("useState<'validation' | 'api' | null>(null)");
+ expect(source).toContain("useState<'detect' | 'save' | null>(null)");
+ expect(source).toContain("actionPhase === 'detecting' ? t('monitor.spinning-tip.detecting') : t('common.loading')");
+ expect(source).toContain('data-monitor-editor-feedback="busy"');
+ expect(source).toContain('data-monitor-editor-busy-contract="angular-spin-tip"');
+ expect(source).toContain('data-monitor-editor-busy-phase={actionPhase}');
+ expect(source).toContain('data-monitor-editor-validation-contract');
+ expect(source).toContain('data-monitor-editor-api-error-contract');
+ expect(source).toContain('data-monitor-editor-blocked-action');
+ expect(source).toContain('data-monitor-editor-blocked-before-api');
+ expect(source).toContain("setActionErrorSource('validation')");
+ expect(source).toContain("setActionErrorSource('api')");
+ expect(source).toContain("setActionErrorAction('detect')");
+ expect(source).toContain("setActionErrorAction('save')");
+ expect(source).toContain("setActionError(error instanceof Error ? error.message : t('monitor.detect.failed'))");
+ expect(source).toContain(
+ "setActionError(error instanceof Error ? error.message : mode === 'new' ? t('monitor.new.failed') : t('monitor.edit.failed'))"
+ );
+ expect(source).toContain('data-monitor-editor-detect-busy-label');
+ expect(source).toContain('data-monitor-editor-submit-busy-label');
+ expect(source).toContain('saveReturnUrl');
+ expect(source).toContain('cancelReturnUrl');
+ expect(source).toContain('data-monitor-editor-save-return');
+ expect(source).toContain('data-monitor-editor-save-return-target');
+ expect(source).toContain('data-monitor-editor-save-notification-contract');
+ expect(source).toContain('data-monitor-editor-cancel-return-target');
+ expect(source).toContain('data-monitor-editor-feedback="error"');
+ expect(source).toContain('data-monitor-editor-feedback="success"');
+ expect(source).not.toContain('className="border-0"');
+ expect(source).toContain('HzMonitorEditorSection');
+ expect(source).toContain('HzKeyValueEditor');
+ expect(source).toContain('LabelRecordInput');
+ expect(source).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(source).toContain('data-monitor-editor-label-selector-owner="cold-label-selector"');
+ expect(source).not.toContain('data-monitor-editor-key-value="labels"');
+ expect(source).toContain('data-monitor-editor-key-value="annotations"');
+ expect(source).toContain('HzField');
+ expect(source).toContain('applyMonitorHostNameAutofill');
+ expect(source).toContain("field: defines[index]?.field");
+ expect(source).toContain('HzInput');
+ expect(source).toContain('data-monitor-editor-input-owner="hertzbeat-ui-input"');
+ expect(source).toContain('data-monitor-editor-app-source="angular-route-context-hidden-field"');
+ expect(source).toContain('data-monitor-editor-hidden-app="route-or-detail-context"');
+ expect(source).toContain('data-monitor-editor-field-order="angular-monitor-form-sequence"');
+ expect(source).toContain('data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"');
+ expect(source).toContain('data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"');
+ expect(source).toContain('data-monitor-editor-payload-param-merge="angular-params-advanced-sdparams"');
+ expect(source).toContain('data-monitor-editor-payload-host-instance="angular-host-param-as-instance"');
+ expect(source).toContain('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"');
+ expect(source).toContain('data-monitor-editor-advanced-visible-contract="angular-visible-param-only"');
+ expect(source).toContain('data-monitor-editor-label-selector="angular-app-label-selector"');
+ expect(source).toContain('data-monitor-editor-advanced-toggle="angular-dashed-collapse-trigger"');
+ expect(source).toContain('setAdvancedOpen(open => !open)');
+ expect(source).toContain('data-monitor-editor-static-host-position="angular-before-name"');
+ expect(source).toContain('data-monitor-editor-static-host-field="angular-before-name"');
+ expect(source).toContain('data-monitor-editor-scrape-param-order="angular-after-scrape-before-name"');
+ expect(source).toContain('data-monitor-editor-runtime-order="angular-after-advanced-before-labels"');
+ expect(source).toContain("define.field === 'host'");
+ expect(source).not.toContain('data-monitor-editor-input="app"');
+ expect(source).toContain('data-monitor-editor-input="name"');
+ expect(source).toContain('data-monitor-editor-input="cron-expression"');
+ expect(source).toContain('data-monitor-editor-cron-required="angular-required-before-detect-save"');
+ expect(source).toContain('required');
+ expect(source).toContain('data-monitor-param-input={define.field}');
+ expect(source).toContain('HzSelect');
+ expect(source).toContain('data-monitor-editor-select-owner="hertzbeat-ui-select"');
+ expect(source).toContain('data-monitor-editor-select="scrape"');
+ expect(source).toContain('data-monitor-editor-select="collector"');
+ expect(source).toContain('data-monitor-editor-select="schedule-type"');
+ expect(source).toContain('data-monitor-param-radio={define.field}');
+ expect(source).toContain('data-monitor-param-radio-contract="angular-nz-radio-group-button-solid"');
+ expect(source).not.toContain('SurfaceSection');
+ expect(source).not.toContain('function KeyValueEditor');
+ expect(source).not.toContain("import { Button } from '@/components/ui/button'");
+ expect(source).not.toContain("import { Input } from '@/components/ui/input'");
+ expect(source).not.toContain("import { Select } from '@/components/ui/select'");
+ expect(source).not.toContain('const fieldLabelClassName');
+ expect(source).not.toContain('text-emerald-300');
+ expect(source).not.toContain('text-rose-300');
+ });
+
+ it('keeps scrape loading feedback owned by @hertzbeat/ui instead of page-local text chrome', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+
+ expect(source).toContain('HzLoadingState');
+ expect(source).toContain('data-monitor-editor-scrape-loading-owner="hertzbeat-ui-loading-state"');
+ expect(source).not.toContain('text-sm text-[var(--ops-text-secondary)]');
+ });
+
+ it('loads dynamic scrape params through the monitor domain facade instead of a raw getter', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+
+ expect(source).toContain('loadMonitorScrapeDraftFromFacade(api.monitors.editorParamDefines');
+ expect(source).toContain("if (scrape === 'static')");
+ expect(source).toContain('data-monitor-editor-service-discovery-params="angular-nonstatic-only"');
+ expect(source).toContain('data-monitor-editor-scrape-reload-contract="angular-reset-on-user-scrape-change"');
+ expect(source).toContain('shouldPreserveMonitorScrapeParamsForLoad(previousScrape, scrape)');
+ expect(source).toContain('shouldPreserveExistingParams ? scrapeParamsRef.current : undefined');
+ expect(source).not.toContain('loadMonitorScrapeDraft(apiMessageGet');
+ expect(source).not.toContain("import { apiMessageGet");
+ });
+
+ it('keeps Angular ssl port auto-change info notification behavior wired in the shared editor', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'api',
+ name: 'checkout',
+ instance: 'example.com:80',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [
+ { field: 'ssl', paramValue: false },
+ { field: 'port', paramValue: 80 }
+ ],
+ paramDefines: [
+ { field: 'ssl', type: 'boolean', name: 'SSL' },
+ { field: 'port', type: 'number', name: 'Port' }
+ ],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="new"
+ />
+ );
+
+ expect(source).toContain('resolveMonitorEditorParamChangeNotice(draft, kind, index, value)');
+ expect(source).toContain('setActionMessage(t(noticeKey))');
+ expect(html).toContain('data-monitor-editor-ssl-port-notice="angular-info-notification"');
+ expect(html).toContain('data-monitor-param-switch="ssl"');
+ expect(html).toContain('data-monitor-param-boolean-contract="angular-nz-switch"');
+ expect(html).toContain('data-monitor-param-number-stepper="port"');
+ });
+
+ it('submits monitor editor mutations through the monitor domain facade instead of raw API writers', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+
+ expect(source).toContain('detectMonitorFromFacade(api.monitors.detect');
+ expect(source).toContain('createMonitorFromFacade(api.monitors.create');
+ expect(source).toContain('updateMonitorFromFacade(api.monitors.update');
+ expect(source).not.toContain("import { apiMessagePost");
+ expect(source).not.toContain("import { apiMessagePut");
+ expect(source).not.toContain('detectMonitor(apiMessagePost');
+ expect(source).not.toContain('createMonitor(apiMessagePost');
+ expect(source).not.toContain('updateMonitor(apiMessagePut');
+ });
+
+ it('keeps Angular monitor form field order for service-discovery params and runtime controls', () => {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'checkout',
+ instance: 'example.com:80',
+ scrape: 'http_sd',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [
+ { field: 'host', paramValue: 'example.com' },
+ { field: 'port', paramValue: 80 }
+ ],
+ paramDefines: [
+ { field: 'host', type: 'text', name: 'Host', required: true },
+ { field: 'port', type: 'number', name: 'Port', required: true }
+ ],
+ advancedParams: [{ field: 'timeout', paramValue: 3 }],
+ advancedParamDefines: [{ field: 'timeout', type: 'number', name: 'Timeout', required: false }],
+ scrapeParams: [{ field: 'sd_url', paramValue: 'https://sd.local/targets' }],
+ scrapeParamDefines: [{ field: 'sd_url', type: 'text', name: 'HTTP SD URL', required: true }],
+ collectors: ['collector-a']
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=website' }}
+ />
+ );
+
+ const scrapeIndex = html.indexOf('data-monitor-editor-select="scrape"');
+ const sdParamIndex = html.indexOf('data-monitor-editor-scrape-param-order="angular-after-scrape-before-name"');
+ const nameIndex = html.indexOf('data-monitor-editor-input="name"');
+ const mainParamIndex = html.indexOf('data-monitor-param-number-stepper="port"');
+ const advancedIndex = html.indexOf('data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"', mainParamIndex);
+ const advancedToggleIndex = html.indexOf('data-monitor-editor-advanced-toggle="angular-dashed-collapse-trigger"', advancedIndex);
+ const runtimeIndex = html.indexOf('data-monitor-editor-runtime-order="angular-after-advanced-before-labels"');
+ const labelIndex = html.indexOf('data-monitor-editor-label-selector-owner="cold-label-selector"', runtimeIndex);
+
+ expect(html).toContain('data-monitor-editor-field-order="angular-monitor-form-sequence"');
+ expect(scrapeIndex).toBeGreaterThan(-1);
+ expect(sdParamIndex).toBeGreaterThan(scrapeIndex);
+ expect(nameIndex).toBeGreaterThan(sdParamIndex);
+ expect(mainParamIndex).toBeGreaterThan(nameIndex);
+ expect(advancedIndex).toBeGreaterThan(mainParamIndex);
+ expect(advancedToggleIndex).toBeGreaterThan(advancedIndex);
+ expect(runtimeIndex).toBeGreaterThan(advancedIndex);
+ expect(labelIndex).toBeGreaterThan(runtimeIndex);
+ expect(html).toContain('data-monitor-editor-advanced-collapse-state="collapsed"');
+ expect(html).not.toContain('data-monitor-param-number-stepper="timeout"');
+ expect(html).not.toContain('data-monitor-editor-advanced-fields="expanded"');
+ });
+
+ it('hides the Angular advanced collapse when every advanced param is dependency-hidden', () => {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'checkout',
+ instance: 'example.com:80',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: 'example.com' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [{ field: 'token', paramValue: '', display: false }],
+ advancedParamDefines: [{ field: 'token', type: 'text', name: 'Token', required: true, depend: { ssl: [true] } }],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ />
+ );
+
+ expect(html).toContain('data-monitor-editor-advanced-visible-contract="angular-visible-param-only"');
+ expect(html).not.toContain('data-monitor-editor-advanced-collapse-state=');
+ expect(html).not.toContain('data-monitor-editor-advanced-toggle="angular-dashed-collapse-trigger"');
+ expect(html).not.toContain('Token');
+ });
+
+ it('hides service-discovery params for static scrape to match Angular nonstatic-only rendering', () => {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'checkout',
+ instance: 'example.com:80',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: 'example.com' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [{ field: 'sd_url', paramValue: 'https://sd.local/targets' }],
+ scrapeParamDefines: [{ field: 'sd_url', type: 'text', name: 'HTTP SD URL', required: true }],
+ collectors: []
+ }}
+ mode="edit"
+ />
+ );
+
+ expect(html).toContain('data-monitor-editor-service-discovery-params="angular-nonstatic-only"');
+ expect(html).toContain('data-monitor-editor-scrape-reload-contract="angular-reset-on-user-scrape-change"');
+ expect(html).not.toContain('data-monitor-editor-scrape-param-order="angular-after-scrape-before-name"');
+ expect(html).toContain('data-monitor-editor-static-host-field="angular-before-name"');
+ });
+
+ it('centers bottom monitor editor actions without repeating the editor title or ready badge', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'prometheus',
+ name: '',
+ instance: '',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: '127.0.0.1' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="new"
+ returnContext={{ returnTo: '/monitors' }}
+ />
+ );
+ const dockHtml = html.slice(html.indexOf('data-monitor-editor-action-dock="bottom"'));
+
+ expect(dockHtml).toContain('data-hz-mutation-summary="hidden"');
+ expect(dockHtml).toContain('data-hz-mutation-action-align="center"');
+ expect(dockHtml).toContain('data-hz-ui="monitor-editor-action-bar"');
+ expect(dockHtml).toContain('data-monitor-editor-action-bar-owner="hertzbeat-ui-monitor-editor-action-bar"');
+ expect(dockHtml).toContain('data-monitor-editor-action="detect"');
+ expect(dockHtml).toContain('data-monitor-editor-action="submit"');
+ expect(dockHtml).toContain('data-monitor-editor-action="cancel"');
+ expect(dockHtml).toContain('data-monitor-editor-detect-action="true"');
+ expect(dockHtml).toContain('data-monitor-editor-submit-action="true"');
+ expect(dockHtml).toContain('Detect');
+ expect(dockHtml).toContain('OK');
+ expect(dockHtml).toContain('Cancel');
+ expect(dockHtml).not.toContain('New monitor');
+ expect(dockHtml).not.toContain('data-hz-mutation-status-label=');
+ expect(dockHtml).not.toContain('ready');
+ expect(source).toContain('HzMonitorEditorActionBar');
+ expect(source).toContain('summaryVisible={false}');
+ expect(source).toContain('actionAlign="center"');
+ });
+
+ it('keeps missing app and name values editable without synthetic shell summaries', () => {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: '',
+ name: '',
+ instance: '',
+ scrape: '',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: '' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-editor-layout="linear"');
+ expect(html).toContain('value=""');
+ expect(html).not.toContain('data-monitor-editor-fact=');
+ expect(html).not.toContain('data-monitor-editor-payload-row=');
+ expect(html).not.toContain('App:-');
+ });
+
+ it('uses the shared HertzBeat UI number stepper for interval schedules instead of native number input', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
const html = renderToStaticMarkup(
<MonitorEditorSurface
@@ -264,17 +1038,67 @@ describe('MonitorEditorSurface', () => {
/>
);
- expect(html).toContain('data-monitor-interval-stepper="cold-number-stepper"');
- expect(html).toContain('data-cold-number-stepper-owner="cold-number-stepper"');
- expect(html).toContain('data-cold-number-stepper-input="true"');
+ expect(html).toContain('data-monitor-interval-stepper="hertzbeat-ui-number-stepper"');
+ expect(html).toContain('data-monitor-interval-stepper-contract="angular-min-step-max-by-app"');
+ expect(html).toContain('data-monitor-interval-stepper-min="10"');
+ expect(html).toContain('data-monitor-interval-stepper-max="604800"');
+ expect(html).toContain('data-monitor-interval-stepper-step="10"');
+ expect(html).toContain('min="10"');
+ expect(html).toContain('max="604800"');
+ expect(html).toContain('step="10"');
+ expect(html).toContain('data-monitor-interval-stepper-unit="common.time.unit.second"');
+ expect(html).toContain('data-monitor-editor-number-stepper-owner="hertzbeat-ui-number-stepper"');
+ expect(html).toContain('data-hz-ui="number-stepper"');
+ expect(html).toContain('data-hz-number-stepper-input="true"');
expect(html).toContain('name="intervals"');
expect(html).not.toContain('type="number"');
- expect(source).toContain("from '../ui/number-stepper'");
- expect(source).toContain('data-monitor-interval-stepper="cold-number-stepper"');
+ expect(source).toContain('HzNumberStepper');
+ expect(source).not.toContain("from '../ui/number-stepper'");
+ expect(source).toContain('data-monitor-interval-stepper="hertzbeat-ui-number-stepper"');
+ expect(source).not.toContain('data-cold-number-stepper-owner');
expect(source).not.toContain('type="number"');
});
- it('uses the shared cold textarea for monitor descriptions instead of the old editor row', () => {
+ it('uses Angular push interval stepper bounds for push monitors', () => {
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'push',
+ name: 'push-input',
+ instance: 'push-input',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 1,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [],
+ paramDefines: [],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=push' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-interval-stepper-contract="angular-min-step-max-by-app"');
+ expect(html).toContain('data-monitor-interval-stepper-min="1"');
+ expect(html).toContain('data-monitor-interval-stepper-max="604800"');
+ expect(html).toContain('data-monitor-interval-stepper-step="1"');
+ expect(html).toContain('min="1"');
+ expect(html).toContain('step="1"');
+ });
+
+ it('uses the shared HertzBeat UI textarea for monitor descriptions instead of the old editor row', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
const html = renderToStaticMarkup(
<MonitorEditorSurface
@@ -307,12 +1131,22 @@ describe('MonitorEditorSurface', () => {
/>
);
- expect(source).toContain("import { Textarea } from '@/components/ui/textarea';");
- expect(source).toContain('data-monitor-description-textarea="cold-textarea"');
+ expect(source).toContain('HzTextarea');
+ expect(source).toContain('data-monitor-description-textarea="hertzbeat-ui-textarea"');
+ expect(source).toContain('data-monitor-editor-textarea-owner="hertzbeat-ui-textarea"');
+ expect(source).toContain('data-monitor-description-textarea-limit="angular-textarea-limit-100"');
+ expect(source).toContain('maxCharacterCount={100}');
+ expect(source).not.toContain("import { Textarea } from '@/components/ui/textarea';");
expect(source).not.toContain("import { EditorRow } from '../observability/editor-rows';");
expect(source).not.toContain('const textAreaClassName =');
- expect(html).toContain('data-monitor-description-textarea="cold-textarea"');
- expect(html).toContain('data-cold-textarea-owner="cold-textarea"');
+ expect(html).toContain('data-monitor-description-textarea="hertzbeat-ui-textarea"');
+ expect(html).toContain('data-monitor-editor-textarea-owner="hertzbeat-ui-textarea"');
+ expect(html).toContain('data-monitor-description-textarea-limit="angular-textarea-limit-100"');
+ expect(html).toContain('data-hz-ui="textarea"');
+ expect(html).toContain('data-hz-ui="textarea-count"');
+ expect(html).toContain('data-hz-textarea-count-max="100"');
+ expect(html).toContain('data-hz-textarea-count-value="24/100"');
+ expect(html).not.toContain('data-cold-textarea-owner="cold-textarea"');
expect(html).toContain('Primary endpoint monitor');
});
@@ -355,14 +1189,29 @@ describe('MonitorEditorSurface', () => {
);
expect(html).toContain('data-monitor-param-number-stepper="port"');
- expect(html).toContain('data-cold-number-stepper-owner="cold-number-stepper"');
- expect(html).toContain('data-cold-number-stepper-input="true"');
+ expect(html).toContain('data-monitor-param-number-contract="angular-nz-input-number--1000-65535-step-1"');
+ expect(html).toContain('data-monitor-param-number-min="-1000"');
+ expect(html).toContain('data-monitor-param-number-max="65535"');
+ expect(html).toContain('data-monitor-param-number-step="1"');
+ expect(html).toContain('data-monitor-editor-number-stepper-owner="hertzbeat-ui-number-stepper"');
+ expect(html).toContain('data-hz-ui="number-stepper"');
+ expect(html).toContain('data-hz-number-stepper-input="true"');
+ expect(html).toContain('min="-1000"');
+ expect(html).toContain('max="65535"');
+ expect(html).toContain('step="1"');
expect(html).not.toContain('type="number"');
+ expect(source).toContain('HzNumberStepper');
expect(source).toContain('data-monitor-param-number-stepper={define.field}');
+ expect(source).toContain('data-monitor-param-number-contract="angular-nz-input-number--1000-65535-step-1"');
+ expect(source).toContain('min={-1000}');
+ expect(source).toContain('max={65535}');
+ expect(source).toContain('step={1}');
+ expect(source).not.toContain("from '../ui/number-stepper'");
+ expect(source).not.toContain('data-cold-number-stepper-owner');
expect(source).not.toContain("type={define.type === 'number' ? 'number' : 'text'}");
});
- it('uses the shared cold checkbox for boolean monitor parameters instead of page-local checkbox chrome', () => {
+ it('renders Angular boolean param definitions as shared switches instead of checkboxes', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
const html = renderToStaticMarkup(
<MonitorEditorSurface
@@ -394,17 +1243,25 @@ describe('MonitorEditorSurface', () => {
/>
);
- expect(html).toContain('data-monitor-param-checkbox="ssl"');
- expect(html).toContain('data-cold-checkbox-owner="cold-checkbox"');
- expect(html).toContain('data-cold-checkbox-control="native-hidden"');
- expect(html).toContain('data-cold-checkbox-box="indicator"');
- expect(html).toContain('data-cold-checkbox-label="true"');
- expect(source).toContain("from '../ui/checkbox'");
- expect(source).toContain('data-monitor-param-checkbox={define.field}');
+ expect(html).toContain('data-monitor-param-switch="ssl"');
+ expect(html).toContain('data-monitor-param-boolean-contract="angular-nz-switch"');
+ expect(html).toContain('data-monitor-param-field="ssl"');
+ expect(html).toContain('data-monitor-editor-switch-owner="hertzbeat-ui-switch"');
+ expect(html).toContain('data-hz-ui="switch"');
+ expect(html).toContain('data-hz-switch-control="button"');
+ expect(html).toContain('data-hz-switch-checked="true"');
+ expect(html).toContain('data-hz-switch-label="true"');
+ expect(source).toContain('HzSwitch');
+ expect(source).not.toContain("from '../ui/checkbox'");
+ expect(source).not.toContain('data-cold-checkbox-owner');
+ expect(source).toContain('data-monitor-param-switch={define.field}');
+ expect(source).toContain('data-monitor-param-boolean-contract="angular-nz-switch"');
+ expect(source).toContain('data-monitor-param-field={define.field}');
+ expect(source).not.toContain('data-monitor-param-checkbox={define.field}');
expect(source).not.toContain('<input type="checkbox" checked={Boolean(field.paramValue)}');
});
- it('uses the shared cold checkbox for the Grafana dashboard enable switch', () => {
+ it('uses the shared HertzBeat UI checkbox for the Grafana dashboard enable switch', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
const html = renderToStaticMarkup(
<MonitorEditorSurface
@@ -436,12 +1293,16 @@ describe('MonitorEditorSurface', () => {
/>
);
- expect(html).toContain('data-monitor-grafana-enabled-checkbox="cold-checkbox"');
- expect(html).toContain('data-cold-checkbox-owner="cold-checkbox"');
- expect(html).toContain('data-cold-checkbox-control="native-hidden"');
- expect(html).toContain('data-cold-checkbox-box="indicator"');
- expect(html).toContain('data-cold-checkbox-label="true"');
- expect(source).toContain('data-monitor-grafana-enabled-checkbox="cold-checkbox"');
+ expect(html).toContain('data-monitor-grafana-enabled-checkbox="hertzbeat-ui-checkbox"');
+ expect(html).toContain('data-monitor-editor-checkbox="grafana-dashboard-enabled"');
+ expect(html).toContain('data-monitor-editor-checkbox-owner="hertzbeat-ui-checkbox"');
+ expect(html).toContain('data-hz-ui="checkbox"');
+ expect(html).toContain('data-hz-checkbox-control="native-hidden"');
+ expect(html).toContain('data-hz-checkbox-box="indicator"');
+ expect(html).toContain('data-hz-checkbox-label="true"');
+ expect(source).toContain('data-monitor-grafana-enabled-checkbox="hertzbeat-ui-checkbox"');
+ expect(source).toContain('data-monitor-editor-checkbox="grafana-dashboard-enabled"');
+ expect(source).not.toContain('data-cold-checkbox-owner');
expect(source).not.toContain('type="checkbox"\n checked={Boolean(draft.grafanaDashboard.enabled)}');
});
@@ -480,17 +1341,222 @@ describe('MonitorEditorSurface', () => {
/>
);
+ expect(html).toContain('data-monitor-editor-code-editor-owner="hertzbeat-ui-code-editor"');
+ expect(html).toContain('data-monitor-editor-code-editor="grafana-dashboard-template"');
+ expect(html).toContain('data-hz-ui="code-editor-frame"');
+ expect(html).toContain('data-hz-code-editor-body="true"');
+ expect(html).toContain('data-hz-code-editor-runtime="codemirror"');
+ expect(html).toContain('data-hz-code-editor-language="json"');
+ expect(html).toContain('data-hz-code-editor-license="codemirror-mit"');
expect(html).toContain('data-monitor-grafana-code-editor="dashboard-template"');
- expect(html).toContain('data-cold-code-editor="codemirror"');
- expect(html).toContain('data-cold-code-editor-language="json"');
+ expect(html).toContain('data-mocked-codemirror="true"');
+ expect(html).toContain('data-min-height="120px"');
expect(html).toContain('name="grafana_dashboard_template"');
expect(html).toContain('{"panels":[]}');
- expect(source).toContain("from '../ui/cold-code-editor'");
+ expect(source).not.toContain("from '../ui/cold-code-editor'");
+ expect(source).toContain('HzCodeEditor');
+ expect(source).toContain('data-monitor-editor-code-editor-owner="hertzbeat-ui-code-editor"');
+ expect(source).toContain('data-monitor-editor-code-editor="grafana-dashboard-template"');
expect(source).toContain('data-monitor-grafana-code-editor="dashboard-template"');
expect(source).not.toContain('grafanaDashboard.enabled ? <EditorRow');
});
- it('uses the shared CodeMirror editor for structured monitor parameter values', () => {
+ it('keeps the Angular Grafana JSON template upload wired through shared file input', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'prometheus',
+ name: 'prometheus',
+ instance: 'prometheus:9090',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: {
+ enabled: true,
+ template: '{"panels":[]}'
+ },
+ params: [{ field: 'host', paramValue: 'prometheus' }],
+ paramDefines: [{ field: 'host', type: 'text', name: 'Host', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=prometheus' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-grafana-template-input-contract="angular-json-template-upload"');
+ expect(html).toContain('data-monitor-grafana-template-upload="angular-json-template-upload"');
+ expect(html).toContain('data-monitor-grafana-template-upload-owner="hertzbeat-ui-file-input"');
+ expect(html).toContain('data-monitor-grafana-template-upload-input="json-template"');
+ expect(html).toContain('data-monitor-grafana-template-upload-trigger-owner="hertzbeat-ui-button"');
+ expect(html).toContain('data-monitor-grafana-template-upload-trigger="json-template"');
+ expect(html).toContain('data-hz-ui="file-input"');
+ expect(html).toContain('accept=".json,application/json"');
+ expect(html).toContain('Upload Grafana Template');
+ expect(source).toContain('HzFileInput');
+ expect(source).toContain('async function handleGrafanaTemplateUpload');
+ expect(source).toContain('file.text()');
+ expect(source).toContain('setGrafanaTemplateText(template)');
+ expect(source).toContain('template');
+ expect(source).not.toContain('data-cold-file-input-owner');
+ });
+
+ it('renders Angular password param definitions as password inputs instead of exposing secrets as text', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'mysql',
+ name: 'mysql-prod',
+ instance: '127.0.0.1:3306',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: {
+ enabled: false
+ },
+ params: [{ field: 'password', paramValue: 'secret' }],
+ paramDefines: [{ field: 'password', type: 'password', name: 'Password', required: true }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=mysql' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-param-password-input="password"');
+ expect(html).toContain('data-monitor-param-password-contract="angular-app-multi-func-password"');
+ expect(html).toContain('data-monitor-param-input="password"');
+ expect(html).toContain('data-monitor-param-field="password"');
+ expect(html).toContain('data-monitor-editor-input-owner="hertzbeat-ui-input"');
+ expect(html).toContain('type="password"');
+ expect(source).toContain("if (define.type === 'password')");
+ expect(source).toContain('data-monitor-param-password-contract="angular-app-multi-func-password"');
+ });
+
+ it('renders Angular textarea param definitions as multi-line shared textareas instead of single-line inputs', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'website-prod',
+ instance: 'https://example.com',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: {
+ enabled: false
+ },
+ params: [{ field: 'body', paramValue: 'line one\nline two' }],
+ paramDefines: [{ field: 'body', type: 'textarea', name: 'Body', required: false }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=website' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-param-textarea="body"');
+ expect(html).toContain('data-monitor-param-textarea-contract="angular-nz-input-textarea-rows-8"');
+ expect(html).toContain('data-monitor-param-field="body"');
+ expect(html).toContain('data-monitor-editor-textarea-owner="hertzbeat-ui-textarea"');
+ expect(html).toContain('data-hz-ui="textarea"');
+ expect(html).toContain('data-hz-textarea-height="tall"');
+ expect(html).not.toContain('data-monitor-param-input="body"');
+ expect(source).toContain("if (define.type === 'textarea')");
+ expect(source).toContain('data-monitor-param-textarea-contract="angular-nz-input-textarea-rows-8"');
+ });
+
+ it('renders Angular radio param definitions as shared button groups instead of select menus', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'website',
+ name: 'checkout',
+ instance: 'example.com:443',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'authType', paramValue: 'basic' }],
+ paramDefines: [
+ {
+ field: 'authType',
+ type: 'radio',
+ name: 'Auth Type',
+ options: [
+ { label: 'Basic', value: 'basic' },
+ { label: 'Bearer', value: 'bearer' }
+ ]
+ }
+ ],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=website' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-param-radio="authType"');
+ expect(html).toContain('data-monitor-param-radio-contract="angular-nz-radio-group-button-solid"');
+ expect(html).toContain('data-monitor-param-field="authType"');
+ expect(html).toContain('data-monitor-editor-radio-owner="hertzbeat-ui-radio-button-group"');
+ expect(html).toContain('data-hz-ui="radio-button-group"');
+ expect(html).toContain('data-hz-radio-button-option="basic"');
+ expect(html).toContain('data-hz-radio-button-option="bearer"');
+ expect(html).toContain('data-hz-radio-button-checked="true"');
+ expect(html).not.toContain('data-monitor-param-select="authType"');
+ expect(source).toContain('HzRadioButtonGroup');
+ expect(source).toContain('data-monitor-param-radio-contract="angular-nz-radio-group-button-solid"');
+ expect(source).not.toContain('data-monitor-param-select={define.field}');
+ });
+
+ it('renders monitor key-value and array params as ordinary shared text controls instead of code editors', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
const html = renderToStaticMarkup(
<MonitorEditorSurface
@@ -514,7 +1580,7 @@ describe('MonitorEditorSurface', () => {
{ field: 'targets', paramValue: '[\n "checkout",\n "orders"\n]' }
],
paramDefines: [
- { field: 'headers', type: 'key-value', name: 'Headers' },
+ { field: 'headers', type: 'key-value', name: 'Headers', keyAlias: 'Header Name', valueAlias: 'Header Value' },
{ field: 'targets', type: 'array', name: 'Targets' }
],
advancedParams: [],
@@ -528,16 +1594,128 @@ describe('MonitorEditorSurface', () => {
/>
);
- expect(html).toContain('data-monitor-param-code-editor="key-value"');
- expect(html).toContain('data-monitor-param-code-editor="array"');
- expect(html).toContain('data-monitor-param-field="headers"');
- expect(html).toContain('data-monitor-param-field="targets"');
- expect(html).toContain('data-cold-code-editor="codemirror"');
- expect(html).toContain('data-cold-code-editor-language="json"');
- expect(html).toContain('"Authorization"');
+ expect(html).toContain('data-monitor-param-key-value-editor="headers"');
+ expect(html).toContain('data-monitor-param-array-input="targets"');
+ expect(html).toContain('data-hz-ui="key-value-editor"');
+ expect(html).toContain('data-hz-key-value-input="key"');
+ expect(html).toContain('data-hz-key-value-input="value"');
+ expect(html).toContain('placeholder="Header Name"');
+ expect(html).toContain('placeholder="Header Value"');
+ expect(html).toContain('value="Authorization"');
+ expect(html).toContain('value="Bearer token"');
expect(html).toContain('"checkout"');
- expect(source).toContain('data-monitor-param-code-editor={define.type}');
- expect(source).toContain('data-monitor-param-field={define.field}');
- expect(source).toContain('onChange={nextValue => onChange(nextValue)}');
+ expect(html).not.toContain('data-monitor-param-code-editor="key-value"');
+ expect(html).not.toContain('data-monitor-param-code-editor="array"');
+ expect(html).not.toContain('data-mocked-codemirror="true"');
+ expect(source).toContain('HzCodeEditor');
+ expect(source).toContain('data-monitor-grafana-code-editor="dashboard-template"');
+ expect(source).toContain('data-monitor-param-key-value-editor={define.field}');
+ expect(source).toContain('data-monitor-param-array-input={define.field}');
+ expect(source).not.toContain('data-monitor-param-code-editor={define.type}');
+ });
+
+ it('renders Angular labels params as configurable key/value rows instead of a plain text input', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'custom',
+ name: 'custom monitor',
+ instance: 'custom:8080',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'labels', paramValue: { team: 'platform', env: 'prod' } }],
+ paramDefines: [{ field: 'labels', type: 'labels', name: 'Labels' }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=custom' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-param-labels-editor="labels"');
+ expect(html).toContain('data-monitor-param-labels-contract="angular-app-configurable-field-key-value"');
+ expect(html).toContain('data-monitor-param-field="labels"');
+ expect(html).toContain('data-monitor-editor-labels-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-monitor-param-labels-input="key"');
+ expect(html).toContain('data-monitor-param-labels-input="value"');
+ expect(html).toContain('data-hz-ui="key-value-editor"');
+ expect(html).toContain('value="team"');
+ expect(html).toContain('value="platform"');
+ expect(html).toContain('value="env"');
+ expect(html).toContain('value="prod"');
+ expect(html).not.toContain('data-monitor-param-input="labels"');
+ expect(source).toContain("if (define.type === 'labels')");
+ expect(source).toContain('data-monitor-param-labels-editor={define.field}');
+ expect(source).toContain('data-monitor-param-labels-contract="angular-app-configurable-field-key-value"');
+ expect(source).toContain("keyInputProps={{ 'data-monitor-param-labels-input': 'key' }}");
+ expect(source).not.toContain("define.type === 'labels' ? 'text'");
+ });
+
+ it('renders Angular metrics-field params as three-column configurable rows instead of a plain text input', () => {
+ const source = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8');
+ const html = renderToStaticMarkup(
+ <MonitorEditorSurface
+ initial={{
+ monitor: {
+ id: 42,
+ app: 'custom',
+ name: 'custom monitor',
+ instance: 'custom:8080',
+ scrape: 'static',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1,
+ labels: {},
+ annotations: {}
+ } as any,
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'metrics', paramValue: [{ field: 'usage', unit: '%', type: 'number' }] }],
+ paramDefines: [{ field: 'metrics', type: 'metrics-field', name: 'Metrics' }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ }}
+ mode="edit"
+ returnContext={{ returnTo: '/monitors?app=custom' }}
+ />
+ );
+
+ expect(html).toContain('data-monitor-param-metrics-field-editor="metrics"');
+ expect(html).toContain('data-monitor-param-metrics-field-contract="angular-app-configurable-field-field-unit-type"');
+ expect(html).toContain('data-monitor-param-field="metrics"');
+ expect(html).toContain('data-monitor-editor-metrics-field-owner="hertzbeat-ui-configurable-field-editor"');
+ expect(html).toContain('data-monitor-param-metrics-field-input="field"');
+ expect(html).toContain('data-monitor-param-metrics-field-input="unit"');
+ expect(html).toContain('data-monitor-param-metrics-field-input="type"');
+ expect(html).toContain('data-hz-ui="configurable-field-editor"');
+ expect(html).toContain('placeholder="Field"');
+ expect(html).toContain('placeholder="Unit"');
+ expect(html).toContain('placeholder="Type"');
+ expect(html).toContain('value="usage"');
+ expect(html).toContain('value="%"');
+ expect(html).toContain('value="number"');
+ expect(html).not.toContain('data-monitor-param-input="metrics"');
+ expect(source).toContain("if (define.type === 'metrics-field')");
+ expect(source).toContain('HzConfigurableFieldEditor');
+ expect(source).toContain('data-monitor-param-metrics-field-editor={define.field}');
+ expect(source).toContain('data-monitor-param-metrics-field-contract="angular-app-configurable-field-field-unit-type"');
+ expect(source).not.toContain("define.type === 'metrics-field' ? 'text'");
});
});
diff --git a/web-next/components/pages/monitor-editor-surface.tsx b/web-next/components/pages/monitor-editor-surface.tsx
index 604c44646f..d966da70ef 100644
--- a/web-next/components/pages/monitor-editor-surface.tsx
+++ b/web-next/components/pages/monitor-editor-surface.tsx
@@ -2,40 +2,171 @@
import React, { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
+import { ChevronDown } from 'lucide-react';
+import { HzButton, HzCheckbox, HzCodeEditor, HzConfigurableFieldEditor, HzField, HzFileInput, HzInlineFeedback, HzInput, HzKeyValueEditor, HzLoadingState, HzMonitorEditorActionBar, HzMonitorEditorFieldGrid, HzMonitorEditorForm, HzMonitorEditorHeader, HzMonitorEditorSection, HzNumberStepper, HzRadioButtonGroup, HzSelect, HzSwitch, HzTextarea, type HzMutationStatus } from '@hertzbeat/ui';
import { useI18n } from '@/components/providers/i18n-provider';
-import { Button } from '@/components/ui/button';
-import { Checkbox } from '../ui/checkbox';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
-import { Textarea } from '@/components/ui/textarea';
-import { ColdCodeEditor } from '../ui/cold-code-editor';
-import { NumberStepper } from '../ui/number-stepper';
-import { SurfaceSection } from '@/components/workbench/primitives';
-import { RowList, WorkbenchPage } from '@/components/workbench/workbench-page';
-import { apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
+import { api } from '@/lib/monitor-api-facade';
import {
- createMonitor,
- detectMonitor,
- loadMonitorScrapeDraft,
+ createMonitorFromFacade,
+ detectMonitorFromFacade,
+ applyMonitorHostNameAutofill,
+ loadMonitorScrapeDraftFromFacade,
+ resolveMonitorEditorParamChangeNotice,
+ shouldPreserveMonitorScrapeParamsForLoad,
syncMonitorDependentDisplay,
+ type MonitorCollectorOption,
type MonitorEditorDraft,
type MonitorEditorMode,
- updateMonitor,
+ updateMonitorFromFacade,
updateMonitorEditorParam,
validateMonitorEditorDraft
} from '@/lib/monitor-editor/controller';
-import { buildMonitorEditorReturnUrl, type MonitorEditorReturnContext } from '@/lib/monitor-editor/navigation';
+import { buildMonitorEditorCancelUrl, buildMonitorEditorReturnUrl, type MonitorEditorReturnContext } from '@/lib/monitor-editor/navigation';
import { resolveLocalizedText } from '@/lib/monitor-editor/localized-text';
import type { KeyValueDraft } from '@/lib/entity-editor/draft-utils';
import { fromKeyValueDraft, toKeyValueDraft } from '@/lib/entity-editor/draft-utils';
-import { ensureKeyValueRows, removeRowAt, updateRowAt } from '@/lib/entity-editor/editor-state';
+import { LabelRecordInput } from '../ui/label-record-input';
-const fieldLabelClassName = 'text-[10px] uppercase tracking-[0.22em] text-[var(--ops-text-tertiary)]';
+function parseMonitorKeyValueRows(value: unknown): KeyValueDraft[] {
+ if (Array.isArray(value)) {
+ const rows = value.map(item => {
+ if (item && typeof item === 'object') {
+ const record = item as Record<string, unknown>;
+ return {
+ key: String(record.key ?? ''),
+ value: String(record.value ?? '')
+ };
+ }
+ return { key: String(item ?? ''), value: '' };
+ });
+ return rows.length > 0 ? rows : [{ key: '', value: '' }];
+ }
+
+ if (value && typeof value === 'object') {
+ return toKeyValueDraft(
+ Object.fromEntries(
+ Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [key, String(entryValue ?? '')])
+ )
+ );
+ }
+
+ if (typeof value !== 'string') {
+ return [{ key: '', value: '' }];
+ }
+
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === '""') {
+ return [{ key: '', value: '' }];
+ }
+
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (Array.isArray(parsed)) {
+ return parseMonitorKeyValueRows(parsed);
+ }
+ if (parsed && typeof parsed === 'object') {
+ return parseMonitorKeyValueRows(parsed);
+ }
+ if (typeof parsed === 'string') {
+ return parsed.trim() ? [{ key: '', value: parsed }] : [{ key: '', value: '' }];
+ }
+ } catch {
+ // Keep non-JSON legacy values visible instead of silently dropping operator input.
+ }
+
+ return [{ key: '', value: trimmed }];
+}
+
+function stringifyMonitorKeyValueRows(rows: KeyValueDraft[]) {
+ return JSON.stringify(fromKeyValueDraft(rows));
+}
+
+function parseMonitorConfigurableRows(value: unknown, keys: string[]): Record<string, string>[] {
+ if (Array.isArray(value)) {
+ const rows = value.map(item => {
+ if (item && typeof item === 'object') {
+ const record = item as Record<string, unknown>;
+ return Object.fromEntries(keys.map(key => [key, String(record[key] ?? '')]));
+ }
+ return Object.fromEntries(keys.map((key, index) => [key, index === 0 ? String(item ?? '') : '']));
+ });
+ return rows.length > 0 ? rows : [{}];
+ }
+
+ if (value && typeof value === 'object') {
+ const record = value as Record<string, unknown>;
+ return [Object.fromEntries(keys.map(key => [key, String(record[key] ?? '')]))];
+ }
+
+ return [{}];
+}
+
+function stringifyMonitorLabelSelectorValue(labels?: Record<string, unknown>) {
+ return Object.entries(labels || {})
+ .filter(([key]) => key.trim())
+ .map(([key, value]) => {
+ const normalizedKey = key.trim();
+ const normalizedValue = String(value ?? '').trim();
+ return normalizedValue ? `${normalizedKey}:${normalizedValue}` : normalizedKey;
+ })
+ .join(', ');
+}
+
+function parseMonitorLabelSelectorValue(value: string) {
+ return value
+ .split(',')
+ .map(item => item.trim())
+ .filter(Boolean)
+ .reduce<Record<string, string>>((acc, item) => {
+ const [rawKey, ...rawValueParts] = item.split(':');
+ const key = rawKey.trim();
+ if (!key) return acc;
+ const labelValue = rawValueParts.join(':').trim();
+ acc[key] = labelValue || key;
+ return acc;
+ }, {});
+}
+
+function normalizeCollectorOption(option: MonitorCollectorOption | string): MonitorCollectorOption | null {
+ if (typeof option === 'string') {
+ return option.trim() ? { name: option.trim() } : null;
+ }
+ const name = option.name?.trim();
+ return name ? { ...option, name } : null;
+}
+
+function isCollectorOnline(option: MonitorCollectorOption) {
+ if (typeof option.online === 'boolean') return option.online;
+ const status = String(option.status ?? '').toLowerCase();
+ return status === '0' || status === 'online' || status === 'true';
+}
+
+function collectorModeLabel(mode: string | null | undefined, t: (key: string) => string) {
+ return mode === 'private' ? t('collector.mode.private') : t('collector.mode.public');
+}
+
+function collectorStatusLabel(option: MonitorCollectorOption, t: (key: string) => string) {
+ return isCollectorOnline(option) ? t('monitor.collector.status.online') : t('monitor.collector.status.offline');
+}
+
+function formatCollectorOptionLabel(option: MonitorCollectorOption, t: (key: string) => string) {
+ return [option.name, option.ip, collectorStatusLabel(option, t), collectorModeLabel(option.mode, t)].filter(Boolean).join(' · ');
+}
+
+function resolveMonitorIntervalStepperBounds(app?: string | null) {
+ const fastInterval = app === 'push';
+ return {
+ min: fastInterval ? 1 : 10,
+ step: fastInterval ? 1 : 10,
+ max: 604800
+ };
+}
function MonitorParamField({
field,
define,
locale,
+ t,
onChange
}: {
field: { paramValue?: unknown };
@@ -45,79 +176,207 @@ function MonitorParamField({
type?: string;
placeholder?: string | Record<string, string>;
options?: Array<{ label?: string | Record<string, string>; value?: string }>;
+ keyAlias?: string;
+ valueAlias?: string;
};
locale: string;
+ t: (key: string) => string;
onChange: (value: unknown) => void;
}) {
const label = resolveLocalizedText(define.name, locale, define.field);
const placeholder = resolveLocalizedText(define.placeholder, locale, '');
if (define.type === 'boolean') {
return (
- <div className="grid gap-2">
- <span className={fieldLabelClassName}>{label}</span>
- <Checkbox
- data-monitor-param-checkbox={define.field}
+ <HzField as="div" label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzSwitch
+ data-monitor-param-switch={define.field}
+ data-monitor-param-boolean-contract="angular-nz-switch"
+ data-monitor-param-field={define.field}
+ data-monitor-editor-switch-owner="hertzbeat-ui-switch"
checked={Boolean(field.paramValue)}
- onChange={e => onChange(e.target.checked)}
+ onCheckedChange={checked => onChange(checked)}
+ aria-label={label}
label={label}
/>
- </div>
+ </HzField>
);
}
if (define.type === 'radio' && (define.options || []).length > 0) {
+ const radioOptions = (define.options || []).map(option => ({
+ value: option.value || '',
+ label: resolveLocalizedText(option.label, locale, option.value || '')
+ }));
+ return (
+ <HzField as="div" label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzRadioButtonGroup
+ name={define.field}
+ value={String(field.paramValue ?? '')}
+ options={radioOptions}
+ data-monitor-param-radio={define.field}
+ data-monitor-param-radio-contract="angular-nz-radio-group-button-solid"
+ data-monitor-param-field={define.field}
+ data-monitor-editor-radio-owner="hertzbeat-ui-radio-button-group"
+ aria-label={label}
+ onChange={value => onChange(value)}
+ />
+ </HzField>
+ );
+ }
+ if (define.type === 'key-value') {
+ return (
+ <HzField as="div" label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzKeyValueEditor
+ data-monitor-param-key-value-editor={define.field}
+ data-monitor-param-field={define.field}
+ data-monitor-editor-key-value-owner="hertzbeat-ui-key-value-editor"
+ rows={parseMonitorKeyValueRows(field.paramValue)}
+ onChange={rows => onChange(stringifyMonitorKeyValueRows(rows))}
+ addLabel={t('common.add')}
+ removeLabel={t('common.remove')}
+ keyPlaceholder={define.keyAlias || t('monitor.editor.labels.key')}
+ valuePlaceholder={define.valueAlias || t('monitor.editor.labels.value')}
+ />
+ </HzField>
+ );
+ }
+ if (define.type === 'labels') {
+ return (
+ <HzField as="div" label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzKeyValueEditor
+ data-monitor-param-labels-editor={define.field}
+ data-monitor-param-labels-contract="angular-app-configurable-field-key-value"
+ data-monitor-param-field={define.field}
+ data-monitor-editor-labels-owner="hertzbeat-ui-key-value-editor"
+ rows={parseMonitorKeyValueRows(field.paramValue)}
+ onChange={rows => onChange(stringifyMonitorKeyValueRows(rows))}
+ addLabel={t('common.add')}
+ removeLabel={t('common.remove')}
+ keyPlaceholder={define.keyAlias || t('monitor.editor.labels.key')}
+ valuePlaceholder={define.valueAlias || t('monitor.editor.labels.value')}
+ keyInputProps={{ 'data-monitor-param-labels-input': 'key' }}
+ valueInputProps={{ 'data-monitor-param-labels-input': 'value' }}
+ />
+ </HzField>
+ );
+ }
+ if (define.type === 'metrics-field') {
+ return (
+ <HzField as="div" label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzConfigurableFieldEditor
+ rows={parseMonitorConfigurableRows(field.paramValue, ['field', 'unit', 'type'])}
+ columns={[
+ {
+ key: 'field',
+ placeholder: 'Field',
+ inputProps: { 'data-monitor-param-metrics-field-input': 'field' } as React.ComponentProps<typeof HzConfigurableFieldEditor>['columns'][number]['inputProps']
+ },
+ {
+ key: 'unit',
+ placeholder: 'Unit',
+ className: 'minmax(50px,90px)',
+ inputProps: { 'data-monitor-param-metrics-field-input': 'unit' } as React.ComponentProps<typeof HzConfigurableFieldEditor>['columns'][number]['inputProps']
+ },
+ {
+ key: 'type',
+ placeholder: 'Type',
+ inputProps: { 'data-monitor-param-metrics-field-input': 'type' } as React.ComponentProps<typeof HzConfigurableFieldEditor>['columns'][number]['inputProps']
+ }
+ ]}
+ onChange={rows => onChange(rows)}
+ addLabel={t('common.add')}
+ removeLabel={t('common.remove')}
+ data-monitor-param-metrics-field-editor={define.field}
+ data-monitor-param-metrics-field-contract="angular-app-configurable-field-field-unit-type"
+ data-monitor-param-field={define.field}
+ data-monitor-editor-metrics-field-owner="hertzbeat-ui-configurable-field-editor"
+ />
+ </HzField>
+ );
+ }
+ if (define.type === 'array') {
+ return (
+ <HzField label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzInput
+ type="text"
+ data-monitor-param-array-input={define.field}
+ data-monitor-param-input={define.field}
+ data-monitor-param-field={define.field}
+ data-monitor-editor-input-owner="hertzbeat-ui-input"
+ value={String(field.paramValue ?? '')}
+ placeholder={placeholder}
+ onChange={e => onChange(e.target.value)}
+ />
+ </HzField>
+ );
+ }
+ if (define.type === 'password') {
return (
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{label}</span>
- <Select value={String(field.paramValue ?? '')} onChange={e => onChange(e.target.value)}>
- {(define.options || []).map(option => (
- <option key={option.value} value={option.value}>
- {resolveLocalizedText(option.label, locale, option.value || '')}
- </option>
- ))}
- </Select>
- </label>
+ <HzField label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzInput
+ type="password"
+ data-monitor-param-password-input={define.field}
+ data-monitor-param-password-contract="angular-app-multi-func-password"
+ data-monitor-param-input={define.field}
+ data-monitor-param-field={define.field}
+ data-monitor-editor-input-owner="hertzbeat-ui-input"
+ value={String(field.paramValue ?? '')}
+ placeholder={placeholder}
+ onChange={e => onChange(e.target.value)}
+ />
+ </HzField>
);
}
- if (define.type === 'key-value' || define.type === 'array') {
+ if (define.type === 'textarea') {
return (
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{label}</span>
- <ColdCodeEditor
- data-monitor-param-code-editor={define.type}
+ <HzField label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzTextarea
+ height="tall"
+ data-monitor-param-textarea={define.field}
+ data-monitor-param-textarea-contract="angular-nz-input-textarea-rows-8"
data-monitor-param-field={define.field}
+ data-monitor-editor-textarea-owner="hertzbeat-ui-textarea"
value={String(field.paramValue ?? '')}
- language="json"
- minHeight="120px"
placeholder={placeholder}
- ariaLabel={label}
- onChange={nextValue => onChange(nextValue)}
+ onChange={e => onChange(e.target.value)}
/>
- </label>
+ </HzField>
);
}
if (define.type === 'number') {
return (
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{label}</span>
- <NumberStepper
+ <HzField as="div" label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzNumberStepper
data-monitor-param-number-stepper={define.field}
+ data-monitor-param-number-contract="angular-nz-input-number--1000-65535-step-1"
+ data-monitor-param-number-min="-1000"
+ data-monitor-param-number-max="65535"
+ data-monitor-param-number-step="1"
+ data-monitor-editor-number-stepper-owner="hertzbeat-ui-number-stepper"
value={String(field.paramValue ?? '')}
+ min={-1000}
+ max={65535}
+ step={1}
placeholder={placeholder}
+ decrementLabel={t('common.decrement')}
+ incrementLabel={t('common.increment')}
onValueChange={value => onChange(value === '' ? null : Number(value))}
/>
- </label>
+ </HzField>
);
}
return (
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{label}</span>
- <Input
+ <HzField label={label} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzInput
type="text"
+ data-monitor-param-input={define.field}
+ data-monitor-param-field={define.field}
+ data-monitor-editor-input-owner="hertzbeat-ui-input"
+ data-monitor-editor-host-name-autofill-contract={define.field === 'host' ? 'angular-new-host-change' : undefined}
value={String(field.paramValue ?? '')}
placeholder={placeholder}
onChange={e => onChange(e.target.value)}
/>
- </label>
+ </HzField>
);
}
@@ -134,53 +393,6 @@ function validateMetadataRows(rows: KeyValueDraft[], sectionLabel: string) {
return null;
}
-function KeyValueEditor({
- title,
- rows,
- onChange,
- addLabel,
- removeLabel,
- keyPlaceholder,
- valuePlaceholder
-}: {
- title: string;
- rows: KeyValueDraft[];
- onChange: (rows: KeyValueDraft[]) => void;
- addLabel: string;
- removeLabel: string;
- keyPlaceholder: string;
- valuePlaceholder: string;
-}) {
- const nextRows = ensureKeyValueRows(rows);
- return (
- <div className="grid gap-3">
- <div className={fieldLabelClassName}>{title}</div>
- {nextRows.map((row, index) => (
- <div key={`${title}-${index}`} className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
- <Input
- placeholder={keyPlaceholder}
- value={row.key}
- onChange={e => onChange(updateRowAt(nextRows, index, { key: e.target.value }))}
- />
- <Input
- placeholder={valuePlaceholder}
- value={row.value}
- onChange={e => onChange(updateRowAt(nextRows, index, { value: e.target.value }))}
- />
- <Button type="button" size="sm" variant="subtle" onClick={() => onChange(removeRowAt(nextRows, index))}>
- {removeLabel}
- </Button>
- </div>
- ))}
- <div className="flex flex-wrap gap-2">
- <Button type="button" size="sm" variant="subtle" onClick={() => onChange([...nextRows, { key: '', value: '' }])}>
- {addLabel}
- </Button>
- </div>
- </div>
- );
-}
-
export function MonitorEditorSurface({
initial,
mode,
@@ -193,20 +405,55 @@ export function MonitorEditorSurface({
const { t, locale } = useI18n();
const router = useRouter();
const [draft, setDraft] = useState(initial);
- const [labelRows, setLabelRows] = useState<KeyValueDraft[]>(toKeyValueDraft(initial.monitor.labels));
+ const [labelSelectorText, setLabelSelectorText] = useState(stringifyMonitorLabelSelectorValue(initial.monitor.labels));
const [annotationRows, setAnnotationRows] = useState<KeyValueDraft[]>(toKeyValueDraft(initial.monitor.annotations));
const [grafanaTemplateText, setGrafanaTemplateText] = useState(initial.grafanaDashboard.template || '');
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
- const [saving, setSaving] = useState(false);
+ const [actionErrorSource, setActionErrorSource] = useState<'validation' | 'api' | null>(null);
+ const [actionErrorAction, setActionErrorAction] = useState<'detect' | 'save' | null>(null);
+ const [actionPhase, setActionPhase] = useState<'idle' | 'detecting' | 'saving'>('idle');
const [scrapeLoading, setScrapeLoading] = useState(false);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const grafanaTemplateInputRef = useRef<HTMLInputElement | null>(null);
const scrapeParamsRef = useRef(draft.scrapeParams);
+ const previousScrapeRef = useRef<string | null>(null);
+ const systemDefaultCollectorLabel = t('monitor.collector.system.default');
+ const scrapeOptions = [
+ { value: 'static', label: 'static' },
+ { value: 'http_sd', label: 'http_sd' },
+ { value: 'nacos_sd', label: 'nacos_sd' },
+ { value: 'dns_sd', label: 'dns_sd' },
+ { value: 'eureka_sd', label: 'eureka_sd' },
+ { value: 'consul_sd', label: 'consul_sd' },
+ { value: 'zookeeper_sd', label: 'zookeeper_sd' }
+ ];
+ const collectorChoices = draft.collectors.map(normalizeCollectorOption).filter((option): option is MonitorCollectorOption => Boolean(option));
+ const selectedCollectorChoice = collectorChoices.find(option => option.name === draft.collector);
+ const defaultCollectorModeLabel = collectorModeLabel('public', t);
+ const intervalStepperBounds = resolveMonitorIntervalStepperBounds(draft.monitor.app);
+ const collectorOptions = [
+ { value: '', label: `${systemDefaultCollectorLabel} - ${defaultCollectorModeLabel}` },
+ ...collectorChoices.map(collector => ({ value: collector.name, label: formatCollectorOptionLabel(collector, t) }))
+ ];
+ const scheduleTypeOptions = [
+ { value: 'interval', label: t('monitor.scheduleType.interval') },
+ { value: 'cron', label: t('monitor.scheduleType.cron') }
+ ];
+ const staticHostParamIndex = draft.paramDefines.findIndex(define => define.field === 'host');
+ const shouldRenderStaticHost =
+ (draft.monitor.scrape || 'static') === 'static' &&
+ staticHostParamIndex >= 0 &&
+ draft.params[staticHostParamIndex]?.display !== false;
+ const hasVisibleAdvancedParams = draft.advancedParams.some(param => param?.display !== false);
useEffect(() => {
+ previousScrapeRef.current = null;
setDraft(syncMonitorDependentDisplay(initial));
- setLabelRows(toKeyValueDraft(initial.monitor.labels));
+ setLabelSelectorText(stringifyMonitorLabelSelectorValue(initial.monitor.labels));
setAnnotationRows(toKeyValueDraft(initial.monitor.annotations));
setGrafanaTemplateText(initial.grafanaDashboard.template || '');
+ setAdvancedOpen(false);
}, [initial]);
useEffect(() => {
@@ -216,8 +463,27 @@ export function MonitorEditorSurface({
useEffect(() => {
const scrape = draft.monitor.scrape || 'static';
let cancelled = false;
+ const previousScrape = previousScrapeRef.current;
+ const shouldPreserveExistingParams = shouldPreserveMonitorScrapeParamsForLoad(previousScrape, scrape);
+ previousScrapeRef.current = scrape;
+ if (scrape === 'static') {
+ setScrapeLoading(false);
+ setDraft(prev => {
+ if (prev.scrapeParams.length === 0 && prev.scrapeParamDefines.length === 0) {
+ return prev;
+ }
+ return syncMonitorDependentDisplay({
+ ...prev,
+ scrapeParams: [],
+ scrapeParamDefines: []
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }
setScrapeLoading(true);
- loadMonitorScrapeDraft(apiMessageGet, scrape, scrapeParamsRef.current)
+ loadMonitorScrapeDraftFromFacade(api.monitors.editorParamDefines, scrape, shouldPreserveExistingParams ? scrapeParamsRef.current : undefined)
.then(result => {
if (cancelled) return;
setDraft(prev =>
@@ -241,7 +507,33 @@ export function MonitorEditorSurface({
}, [draft.monitor.scrape, t]);
function updateParam(kind: 'params' | 'advancedParams' | 'scrapeParams', index: number, value: unknown) {
- setDraft(prev => updateMonitorEditorParam(prev, kind, index, value));
+ const noticeKey = resolveMonitorEditorParamChangeNotice(draft, kind, index, value);
+ setDraft(prev => {
+ const nextDraft = updateMonitorEditorParam(prev, kind, index, value);
+ const defines = kind === 'params' ? prev.paramDefines : kind === 'advancedParams' ? prev.advancedParamDefines : prev.scrapeParamDefines;
+ return applyMonitorHostNameAutofill(nextDraft, { mode, field: defines[index]?.field });
+ });
+ if (noticeKey) {
+ setActionMessage(t(noticeKey));
+ setActionError(null);
+ setActionErrorSource(null);
+ setActionErrorAction(null);
+ }
+ }
+
+ async function handleGrafanaTemplateUpload(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file) return;
+ const template = await file.text();
+ setGrafanaTemplateText(template);
+ setDraft(prev => ({
+ ...prev,
+ grafanaDashboard: {
+ ...prev.grafanaDashboard,
+ template
+ }
+ }));
}
function buildDraftForSubmit() {
@@ -249,7 +541,7 @@ export function MonitorEditorSurface({
...draft,
monitor: {
...draft.monitor,
- labels: fromKeyValueDraft(labelRows),
+ labels: parseMonitorLabelSelectorValue(labelSelectorText),
annotations: fromKeyValueDraft(annotationRows)
},
grafanaDashboard: {
@@ -259,250 +551,530 @@ export function MonitorEditorSurface({
} satisfies MonitorEditorDraft;
}
- function validateBeforeSubmit(nextDraft: MonitorEditorDraft) {
- const labelError = validateMetadataRows(labelRows, t('label.bind'));
+ function validateBeforeSubmit(nextDraft: MonitorEditorDraft, options: { validateCronFormat?: boolean } = {}) {
+ const labelError = validateMetadataRows(toKeyValueDraft(parseMonitorLabelSelectorValue(labelSelectorText)), t('label.bind'));
if (labelError) return labelError;
const annotationError = validateMetadataRows(annotationRows, t('common.annotation.bind'));
if (annotationError) return annotationError;
- return validateMonitorEditorDraft(nextDraft, t);
+ return validateMonitorEditorDraft(nextDraft, t, options);
}
+ const actionBusy = actionPhase !== 'idle';
+ const busyActionLabel = actionPhase === 'detecting' ? t('monitor.spinning-tip.detecting') : t('common.loading');
+ const mutationStatus: HzMutationStatus = actionBusy ? 'saving' : actionError ? 'failed' : actionMessage ? 'saved' : 'clean';
+ const saveReturnUrl = buildMonitorEditorReturnUrl(draft.monitor.app, returnContext ? { ...returnContext } : undefined);
+ const cancelReturnUrl = buildMonitorEditorCancelUrl(returnContext ? { ...returnContext } : undefined);
+ const saveReturnContract = returnContext?.returnTo ? 'safe-return-context-or-angular-app-list' : 'angular-app-list';
+ const cancelReturnContract = returnContext?.returnTo ? 'safe-return-context-or-list-root' : 'legacy-list-root';
+ const mutationStatusLabel =
+ actionBusy
+ ? busyActionLabel
+ : mutationStatus === 'failed'
+ ? t('common.failed')
+ : mutationStatus === 'saved'
+ ? actionMessage || t('common.ready')
+ : t('common.ready');
+ const mutationFeedback = actionBusy ? (
+ <HzInlineFeedback
+ tone="info"
+ title={busyActionLabel}
+ variant="embedded"
+ data-monitor-editor-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-monitor-editor-feedback="busy"
+ data-monitor-editor-busy-contract="angular-spin-tip"
+ data-monitor-editor-busy-phase={actionPhase}
+ />
+ ) : actionError ? (
+ <HzInlineFeedback
+ tone="critical"
+ title={t('common.failed')}
+ description={actionError}
+ variant="embedded"
+ data-monitor-editor-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-monitor-editor-feedback="error"
+ data-monitor-editor-validation-contract={actionErrorSource === 'validation' ? 'angular-form-required' : undefined}
+ data-monitor-editor-api-error-contract={actionErrorSource === 'api' ? 'angular-message-msg' : undefined}
+ data-monitor-editor-blocked-action={actionErrorAction || undefined}
+ data-monitor-editor-blocked-before-api={actionErrorSource === 'validation' ? 'true' : undefined}
+ />
+ ) : actionMessage ? (
+ <HzInlineFeedback
+ tone="success"
+ title={actionMessage}
+ variant="embedded"
+ data-monitor-editor-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-monitor-editor-feedback="success"
+ />
+ ) : null;
+ const mutationBar = (
+ <HzMonitorEditorActionBar
+ title={mode === 'new' ? t('monitor.new-monitor') : draft.monitor.name || t('monitor.edit-monitor')}
+ status={mutationStatus}
+ statusLabel={mutationStatusLabel}
+ summaryVisible={false}
+ actionAlign="center"
+ feedback={mutationFeedback}
+ data-monitor-editor-action-dock="bottom"
+ data-monitor-editor-mutation-bar="true"
+ actions={[
+ {
+ id: 'detect',
+ label: t('common.button.detect'),
+ intent: 'ghost',
+ disabled: actionBusy,
+ onSelect: () => void handleDetect(),
+ buttonProps: {
+ 'data-monitor-editor-detect-action': 'true',
+ 'data-monitor-editor-detect-busy-label': t('monitor.spinning-tip.detecting'),
+ 'data-monitor-editor-detect-cron-validation': 'angular-detect-skips-cron-format'
+ }
+ },
+ {
+ id: 'submit',
+ label: t('common.button.ok'),
+ type: 'submit',
+ intent: 'primary',
+ disabled: actionBusy,
+ buttonProps: {
+ 'data-monitor-editor-submit-action': 'true',
+ 'data-monitor-editor-submit-busy-label': t('common.loading'),
+ 'data-monitor-editor-save-return': saveReturnContract,
+ 'data-monitor-editor-save-return-target': saveReturnUrl,
+ 'data-monitor-editor-save-notification-contract': 'angular-success-before-return'
+ }
+ },
+ {
+ id: 'cancel',
+ label: t('common.button.cancel'),
+ intent: 'ghost',
+ onSelect: () => router.push(cancelReturnUrl),
+ buttonProps: {
+ 'data-monitor-editor-cancel-action': 'true',
+ 'data-monitor-editor-cancel-return': cancelReturnContract,
+ 'data-monitor-editor-cancel-return-target': cancelReturnUrl
+ }
+ }
+ ]}
+ />
+ );
+
async function handleDetect() {
- setSaving(true);
+ setActionPhase('detecting');
+ setActionErrorSource(null);
+ setActionErrorAction(null);
try {
const nextDraft = buildDraftForSubmit();
- const validationError = validateBeforeSubmit(nextDraft);
+ const validationError = validateBeforeSubmit(nextDraft, { validateCronFormat: false });
if (validationError) {
setActionError(validationError);
+ setActionErrorSource('validation');
+ setActionErrorAction('detect');
setActionMessage(null);
return;
}
- await detectMonitor(apiMessagePost as any, nextDraft);
+ await detectMonitorFromFacade(api.monitors.detect, nextDraft);
setActionMessage(t('monitor.detect.success'));
setActionError(null);
+ setActionErrorSource(null);
+ setActionErrorAction(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : t('monitor.detect.failed'));
+ setActionErrorSource('api');
+ setActionErrorAction('detect');
} finally {
- setSaving(false);
+ setActionPhase('idle');
}
}
async function handleSave() {
- setSaving(true);
+ setActionPhase('saving');
+ setActionErrorSource(null);
+ setActionErrorAction(null);
try {
const nextDraft = buildDraftForSubmit();
const validationError = validateBeforeSubmit(nextDraft);
if (validationError) {
setActionError(validationError);
+ setActionErrorSource('validation');
+ setActionErrorAction('save');
setActionMessage(null);
return;
}
if (mode === 'new') {
- await createMonitor(apiMessagePost as any, nextDraft);
+ await createMonitorFromFacade(api.monitors.create, nextDraft);
setActionMessage(t('monitor.new.success'));
} else {
- await updateMonitor(apiMessagePut as any, nextDraft);
+ await updateMonitorFromFacade(api.monitors.update, nextDraft);
setActionMessage(t('monitor.edit.success'));
}
setActionError(null);
+ setActionErrorSource(null);
+ setActionErrorAction(null);
router.push(buildMonitorEditorReturnUrl(nextDraft.monitor.app, returnContext ? { ...returnContext } : undefined));
} catch (error) {
setActionError(error instanceof Error ? error.message : mode === 'new' ? t('monitor.new.failed') : t('monitor.edit.failed'));
+ setActionErrorSource('api');
+ setActionErrorAction('save');
} finally {
- setSaving(false);
+ setActionPhase('idle');
}
}
return (
- <form
- className="space-y-3"
+ <HzMonitorEditorForm
+ actionBar={mutationBar}
onSubmit={event => {
event.preventDefault();
void handleSave();
}}
+ data-monitor-editor-form-owner="hertzbeat-ui-monitor-editor-form"
+ data-monitor-editor-app-source="angular-route-context-hidden-field"
+ data-monitor-editor-static-host-position="angular-before-name"
+ data-monitor-editor-field-order="angular-monitor-form-sequence"
+ data-monitor-editor-detect-payload-contract="angular-monitor-collector-params-no-grafana"
+ data-monitor-editor-save-payload-contract="angular-monitor-collector-params-grafana"
+ data-monitor-editor-payload-param-merge="angular-params-advanced-sdparams"
+ data-monitor-editor-payload-host-instance="angular-host-param-as-instance"
+ data-monitor-editor-service-discovery-params="angular-nonstatic-only"
+ data-monitor-editor-scrape-reload-contract="angular-reset-on-user-scrape-change"
+ data-monitor-editor-ssl-port-notice="angular-info-notification"
+ data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"
+ data-monitor-editor-advanced-visible-contract="angular-visible-param-only"
+ data-monitor-editor-label-selector="angular-app-label-selector"
+ data-monitor-editor-collector-selection="angular-collectors-selection-tags"
+ data-monitor-editor-detect-cron-validation="angular-detect-skips-cron-format"
+ data-monitor-editor-cron-required="angular-required-before-detect-save"
>
- <WorkbenchPage
- kicker={t('monitor.form.shell.kicker')}
+ <input
+ type="hidden"
+ name="app"
+ value={draft.monitor.app || ''}
+ readOnly
+ data-monitor-editor-hidden-app="route-or-detail-context"
+ />
+ <HzMonitorEditorHeader
title={mode === 'new' ? t('monitor.new-monitor') : draft.monitor.name || t('monitor.edit-monitor')}
- subtitle={mode === 'new' ? t('monitor.form.shell.new.copy') : t('monitor.form.shell.edit.copy')}
- tone="operator"
- facts={[
- { label: t('common.app'), value: draft.monitor.app || '-' },
- { label: t('common.scrape'), value: draft.monitor.scrape || 'static' },
- { label: t('common.mode'), value: mode }
- ]}
- actions={
- <>
- <Button type="button" size="sm" variant="default" onClick={() => void handleDetect()} disabled={saving}>
- {t('common.button.detect')}
- </Button>
- <Button type="submit" size="sm" variant="primary" disabled={saving}>
- {t('common.button.ok')}
- </Button>
- <Button
+ data-monitor-editor-linear-header="hertzbeat-ui-monitor-editor-header"
+ />
+
+ <HzMonitorEditorSection data-monitor-editor-section-owner="hertzbeat-ui-editor-section" title={t('monitor.editor.section.base')}>
+ <HzMonitorEditorFieldGrid data-monitor-editor-field-grid-owner="hertzbeat-ui-monitor-editor-field-grid">
+ <HzField label={t('monitor.scrape')} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzSelect
+ value={draft.monitor.scrape || 'static'}
+ aria-label={t('monitor.scrape')}
+ options={scrapeOptions}
+ data-monitor-editor-select="scrape"
+ data-monitor-editor-select-owner="hertzbeat-ui-select"
+ onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, scrape: e.target.value } }))}
+ />
+ </HzField>
+ {draft.monitor.scrape !== 'static'
+ ? draft.scrapeParamDefines.map((define, index) => (
+ draft.scrapeParams[index]?.display === false ? null : (
+ <div
+ key={`scrape-${define.field}`}
+ data-monitor-editor-scrape-param-order="angular-after-scrape-before-name"
+ >
+ <MonitorParamField
+ field={draft.scrapeParams[index] || {}}
+ define={define}
+ locale={locale}
+ t={t}
+ onChange={value => updateParam('scrapeParams', index, value)}
+ />
+ </div>
+ )
+ ))
+ : null}
+ {shouldRenderStaticHost ? (
+ <div data-monitor-editor-static-host-field="angular-before-name">
+ <MonitorParamField
+ field={draft.params[staticHostParamIndex] || {}}
+ define={draft.paramDefines[staticHostParamIndex]}
+ locale={locale}
+ t={t}
+ onChange={value => updateParam('params', staticHostParamIndex, value)}
+ />
+ </div>
+ ) : null}
+ <HzField label={t('monitor.name')} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzInput
+ value={draft.monitor.name || ''}
+ data-monitor-editor-input="name"
+ data-monitor-editor-input-owner="hertzbeat-ui-input"
+ data-monitor-editor-host-name-autofill-target={mode === 'new' ? 'monitor-name' : undefined}
+ onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, name: e.target.value } }))}
+ />
+ </HzField>
+ </HzMonitorEditorFieldGrid>
+ </HzMonitorEditorSection>
+
+ <HzMonitorEditorSection data-monitor-editor-section-owner="hertzbeat-ui-editor-section" title={t('monitor.editor.section.params')}>
+ <HzMonitorEditorFieldGrid data-monitor-editor-field-grid-owner="hertzbeat-ui-monitor-editor-field-grid">
+ {draft.paramDefines.map((define, index) => (
+ draft.params[index]?.display === false || define.field === 'host' ? null : <MonitorParamField key={`param-${define.field}`} field={draft.params[index] || {}} define={define} locale={locale} t={t} onChange={value => updateParam('params', index, value)} />
+ ))}
+ </HzMonitorEditorFieldGrid>
+ {scrapeLoading ? (
+ <HzLoadingState
+ data-monitor-editor-scrape-loading-owner="hertzbeat-ui-loading-state"
+ title={t('common.loading')}
+ rows={2}
+ />
+ ) : null}
+ </HzMonitorEditorSection>
+
+ {hasVisibleAdvancedParams ? (
+ <HzMonitorEditorSection
+ data-monitor-editor-section-owner="hertzbeat-ui-editor-section"
+ data-monitor-editor-advanced-collapse="angular-ghost-collapse-dashed-trigger"
+ data-monitor-editor-advanced-collapse-state={advancedOpen ? 'expanded' : 'collapsed'}
+ title={t('monitor.advanced')}
+ >
+ <HzButton
type="button"
size="sm"
- variant="subtle"
- data-monitor-editor-cancel-action="true"
- onClick={() => router.push(buildMonitorEditorReturnUrl(draft.monitor.app, returnContext ? { ...returnContext } : undefined))}
+ intent="secondary"
+ aria-expanded={advancedOpen}
+ aria-controls="monitor-editor-advanced-params"
+ data-monitor-editor-advanced-toggle="angular-dashed-collapse-trigger"
+ data-monitor-editor-advanced-toggle-owner="hertzbeat-ui-button"
+ onClick={() => setAdvancedOpen(open => !open)}
>
- {t('common.button.cancel')}
- </Button>
- </>
- }
- main={
- <div className="space-y-4">
- <SurfaceSection title={t('monitor.editor.section.base')} copy={t('monitor.editor.section.base.copy')}>
- <div className="grid gap-3 md:grid-cols-2">
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('common.app')}</span>
- <Input value={draft.monitor.app || ''} onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, app: e.target.value } }))} disabled={mode === 'edit'} />
- </label>
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('monitor.scrape')}</span>
- <Select value={draft.monitor.scrape || 'static'} onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, scrape: e.target.value } }))}>
- <option value="static">static</option>
- <option value="http_sd">http_sd</option>
- <option value="nacos_sd">nacos_sd</option>
- <option value="dns_sd">dns_sd</option>
- <option value="eureka_sd">eureka_sd</option>
- <option value="consul_sd">consul_sd</option>
- <option value="zookeeper_sd">zookeeper_sd</option>
- </Select>
- </label>
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('monitor.name')}</span>
- <Input value={draft.monitor.name || ''} onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, name: e.target.value } }))} />
- </label>
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('monitor.collector')}</span>
- <Select value={draft.collector || ''} onChange={e => setDraft(prev => ({ ...prev, collector: e.target.value }))}>
- <option value="">{t('common.none')}</option>
- {draft.collectors.map(collector => (
- <option key={collector} value={collector}>
- {collector}
- </option>
- ))}
- </Select>
- </label>
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('monitor.scheduleType')}</span>
- <Select value={draft.monitor.scheduleType || 'interval'} onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, scheduleType: e.target.value } }))}>
- <option value="interval">{t('monitor.scheduleType.interval')}</option>
- <option value="cron">{t('monitor.scheduleType.cron')}</option>
- </Select>
- </label>
- {draft.monitor.scheduleType === 'cron' ? (
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('monitor.cronExpression')}</span>
- <Input value={draft.monitor.cronExpression || ''} onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, cronExpression: e.target.value } }))} />
- </label>
- ) : (
- <label className="grid gap-2">
- <span className={fieldLabelClassName}>{t('monitor.intervals')}</span>
- <NumberStepper
- name="intervals"
- min="1"
- step="1"
- data-monitor-interval-stepper="cold-number-stepper"
- value={String(draft.monitor.intervals ?? 60)}
- onValueChange={value => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, intervals: value === '' ? null : Number(value) } }))}
- />
- </label>
- )}
- </div>
- </SurfaceSection>
-
- <SurfaceSection title={t('monitor.editor.section.params')} copy={t('monitor.editor.section.params.copy')}>
- <div className="grid gap-3 md:grid-cols-2">
- {draft.paramDefines.map((define, index) => (
- draft.params[index]?.display === false ? null : <MonitorParamField key={`param-${define.field}`} field={draft.params[index] || {}} define={define} locale={locale} onChange={value => updateParam('params', index, value)} />
- ))}
- {draft.scrapeParamDefines.map((define, index) => (
- draft.scrapeParams[index]?.display === false ? null : <MonitorParamField key={`scrape-${define.field}`} field={draft.scrapeParams[index] || {}} define={define} locale={locale} onChange={value => updateParam('scrapeParams', index, value)} />
+ <ChevronDown
+ aria-hidden="true"
+ className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`}
+ />
+ {t('monitor.advanced')}
+ </HzButton>
+ {advancedOpen ? (
+ <HzMonitorEditorFieldGrid
+ id="monitor-editor-advanced-params"
+ className="mt-3"
+ data-monitor-editor-field-grid-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-monitor-editor-advanced-fields="expanded"
+ >
+ {draft.advancedParamDefines.map((define, index) => (
+ draft.advancedParams[index]?.display === false ? null : <MonitorParamField key={`advanced-${define.field}`} field={draft.advancedParams[index] || {}} define={define} locale={locale} t={t} onChange={value => updateParam('advancedParams', index, value)} />
))}
- </div>
- {scrapeLoading ? <div className="text-sm text-[var(--ops-text-secondary)]">{t('common.loading')}</div> : null}
- </SurfaceSection>
-
- {draft.advancedParamDefines.length > 0 ? (
- <SurfaceSection title={t('monitor.advanced')} copy={t('monitor.editor.section.advanced.copy')}>
- <div className="grid gap-3 md:grid-cols-2">
- {draft.advancedParamDefines.map((define, index) => (
- draft.advancedParams[index]?.display === false ? null : <MonitorParamField key={`advanced-${define.field}`} field={draft.advancedParams[index] || {}} define={define} locale={locale} onChange={value => updateParam('advancedParams', index, value)} />
- ))}
- </div>
- </SurfaceSection>
+ </HzMonitorEditorFieldGrid>
) : null}
- </div>
- }
- side={
- <>
- <SurfaceSection title={t('common.description')} copy={t('monitor.description.tip')}>
- <Textarea
- data-monitor-description-textarea="cold-textarea"
- className="min-h-[120px]"
- value={draft.monitor.description || ''}
- onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, description: e.target.value } }))}
- />
- </SurfaceSection>
- <SurfaceSection title={t('label.bind')} copy={t('monitor.editor.labels.copy')}>
- <KeyValueEditor
- title={t('label.bind')}
- rows={labelRows}
- onChange={setLabelRows}
- addLabel={t('monitor.editor.labels.add')}
- removeLabel={t('monitor.editor.labels.remove')}
- keyPlaceholder={t('monitor.editor.labels.key')}
- valuePlaceholder={t('monitor.editor.labels.value')}
+ </HzMonitorEditorSection>
+ ) : null}
+
+ <HzMonitorEditorSection
+ data-monitor-editor-section-owner="hertzbeat-ui-editor-section"
+ data-monitor-editor-runtime-order="angular-after-advanced-before-labels"
+ title={t('monitor.editor.section.runtime')}
+ >
+ <HzMonitorEditorFieldGrid data-monitor-editor-field-grid-owner="hertzbeat-ui-monitor-editor-field-grid">
+ <HzField
+ label={t('monitor.collector')}
+ data-monitor-editor-field-owner="hertzbeat-ui-field"
+ data-monitor-editor-collector-selection="angular-collectors-selection-tags"
+ >
+ <HzSelect
+ value={draft.collector || ''}
+ aria-label={t('monitor.collector')}
+ options={collectorOptions}
+ data-monitor-editor-select="collector"
+ data-monitor-editor-select-owner="hertzbeat-ui-select"
+ data-monitor-editor-collector-selection-owner="hertzbeat-ui-select"
+ optionDataAttributes={option => ({
+ 'data-monitor-editor-collector-option': option.value || 'system-default',
+ 'data-monitor-editor-collector-option-contract': 'angular-status-ip-mode-tags'
+ })}
+ onChange={e => setDraft(prev => ({ ...prev, collector: e.target.value }))}
/>
- </SurfaceSection>
- <SurfaceSection title={t('common.annotation.bind')} copy={t('monitor.editor.annotations.copy')}>
- <KeyValueEditor
- title={t('common.annotation.bind')}
- rows={annotationRows}
- onChange={setAnnotationRows}
- addLabel={t('monitor.editor.annotations.add')}
- removeLabel={t('monitor.editor.annotations.remove')}
- keyPlaceholder={t('monitor.editor.annotations.key')}
- valuePlaceholder={t('monitor.editor.annotations.value')}
+ <div
+ className="mt-2 flex min-h-6 flex-wrap items-center gap-1.5"
+ data-monitor-editor-collector-tags="angular-status-ip-mode-tags"
+ >
+ {selectedCollectorChoice ? (
+ <>
+ <span className="border border-[#263552] bg-[#10203a] px-2 py-0.5 text-[10px] font-semibold text-[#d8e4ff]" data-monitor-editor-collector-tag="status">
+ {collectorStatusLabel(selectedCollectorChoice, t)}
+ </span>
+ <span className="border border-[#263552] bg-[#10203a] px-2 py-0.5 text-[10px] font-semibold text-[#d8e4ff]" data-monitor-editor-collector-tag="name">
+ {selectedCollectorChoice.name}
+ </span>
+ {selectedCollectorChoice.ip ? (
+ <span className="border border-[#263552] bg-[#10203a] px-2 py-0.5 text-[10px] font-semibold text-[#d8e4ff]" data-monitor-editor-collector-tag="ip">
+ {selectedCollectorChoice.ip}
+ </span>
+ ) : null}
+ <span className="border border-[#263552] bg-[#10203a] px-2 py-0.5 text-[10px] font-semibold text-[#d8e4ff]" data-monitor-editor-collector-tag="mode">
+ {collectorModeLabel(selectedCollectorChoice.mode, t)}
+ </span>
+ </>
+ ) : (
+ <>
+ <span className="border border-[#263552] bg-[#10203a] px-2 py-0.5 text-[10px] font-semibold text-[#d8e4ff]" data-monitor-editor-collector-tag="system-default">
+ {systemDefaultCollectorLabel}
+ </span>
+ <span className="border border-[#1f4537] bg-[#0d2a20] px-2 py-0.5 text-[10px] font-semibold text-[#b7f7d0]" data-monitor-editor-collector-tag="mode">
+ {defaultCollectorModeLabel}
+ </span>
+ </>
+ )}
+ </div>
+ </HzField>
+ <HzField label={t('monitor.scheduleType')} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzSelect
+ value={draft.monitor.scheduleType || 'interval'}
+ aria-label={t('monitor.scheduleType')}
+ options={scheduleTypeOptions}
+ data-monitor-editor-select="schedule-type"
+ data-monitor-editor-select-owner="hertzbeat-ui-select"
+ onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, scheduleType: e.target.value } }))}
/>
- </SurfaceSection>
- {draft.monitor.app === 'prometheus' ? (
- <SurfaceSection title={t('monitor.grafana.enabled.label')} copy={t('monitor.grafana.upload.tip')}>
- <Checkbox
- containerClassName="mb-3"
- data-monitor-grafana-enabled-checkbox="cold-checkbox"
- checked={Boolean(draft.grafanaDashboard.enabled)}
- onChange={e => setDraft(prev => ({ ...prev, grafanaDashboard: { ...prev.grafanaDashboard, enabled: e.target.checked } }))}
- label={t('monitor.grafana.enabled.label')}
+ </HzField>
+ {draft.monitor.scheduleType === 'cron' ? (
+ <HzField label={t('monitor.cronExpression')} data-monitor-editor-field-owner="hertzbeat-ui-field">
+ <HzInput
+ value={draft.monitor.cronExpression || ''}
+ required
+ data-monitor-editor-input="cron-expression"
+ data-monitor-editor-input-owner="hertzbeat-ui-input"
+ data-monitor-editor-cron-required="angular-required-before-detect-save"
+ onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, cronExpression: e.target.value } }))}
/>
- {draft.grafanaDashboard.enabled ? (
- <ColdCodeEditor
- name="grafana_dashboard_template"
- value={grafanaTemplateText}
- language="json"
- minHeight="120px"
- ariaLabel={t('monitor.grafana.enabled.label')}
- data-monitor-grafana-code-editor="dashboard-template"
- onChange={setGrafanaTemplateText}
+ </HzField>
+ ) : (
+ <HzField
+ as="div"
+ label={t('monitor.intervals')}
+ data-monitor-editor-field-owner="hertzbeat-ui-field"
+ data-monitor-interval-stepper-contract="angular-min-step-max-by-app"
+ >
+ <HzNumberStepper
+ name="intervals"
+ min={intervalStepperBounds.min}
+ max={intervalStepperBounds.max}
+ step={intervalStepperBounds.step}
+ data-monitor-interval-stepper="hertzbeat-ui-number-stepper"
+ data-monitor-interval-stepper-min={String(intervalStepperBounds.min)}
+ data-monitor-interval-stepper-max={String(intervalStepperBounds.max)}
+ data-monitor-interval-stepper-step={String(intervalStepperBounds.step)}
+ data-monitor-editor-number-stepper-owner="hertzbeat-ui-number-stepper"
+ value={String(draft.monitor.intervals ?? 60)}
+ decrementLabel={t('common.decrement')}
+ incrementLabel={t('common.increment')}
+ onValueChange={value => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, intervals: value === '' ? null : Number(value) } }))}
+ />
+ <span
+ className="mt-1 inline-flex text-[10px] font-semibold text-[#7d8798]"
+ data-monitor-interval-stepper-unit="common.time.unit.second"
+ >
+ {t('common.time.unit.second')}
+ </span>
+ </HzField>
+ )}
+ </HzMonitorEditorFieldGrid>
+ </HzMonitorEditorSection>
+
+ <HzMonitorEditorSection data-monitor-editor-section-owner="hertzbeat-ui-editor-section" title={t('label.bind')}>
+ <div
+ data-monitor-editor-label-selector="angular-app-label-selector"
+ data-monitor-editor-label-selector-owner="cold-label-selector"
+ >
+ <LabelRecordInput
+ name="labels"
+ value={labelSelectorText}
+ onValueChange={setLabelSelectorText}
+ keyPlaceholder={t('monitor.editor.labels.key')}
+ valuePlaceholder={t('monitor.editor.labels.value')}
+ addLabel={t('monitor.editor.labels.add')}
+ removeLabel={t('monitor.editor.labels.remove')}
+ containerClassName="min-w-0"
+ />
+ </div>
+ </HzMonitorEditorSection>
+
+ <HzMonitorEditorSection data-monitor-editor-section-owner="hertzbeat-ui-editor-section" title={t('common.annotation.bind')}>
+ <HzKeyValueEditor
+ data-monitor-editor-key-value="annotations"
+ data-monitor-editor-key-value-owner="hertzbeat-ui-key-value-editor"
+ title={t('common.annotation.bind')}
+ rows={annotationRows}
+ onChange={setAnnotationRows}
+ addLabel={t('monitor.editor.annotations.add')}
+ removeLabel={t('monitor.editor.annotations.remove')}
+ keyPlaceholder={t('monitor.editor.annotations.key')}
+ valuePlaceholder={t('monitor.editor.annotations.value')}
+ />
+ </HzMonitorEditorSection>
+
+ <HzMonitorEditorSection data-monitor-editor-section-owner="hertzbeat-ui-editor-section" title={t('common.description')}>
+ <HzTextarea
+ data-monitor-description-textarea="hertzbeat-ui-textarea"
+ data-monitor-editor-textarea-owner="hertzbeat-ui-textarea"
+ data-monitor-description-textarea-limit="angular-textarea-limit-100"
+ height="tall"
+ maxCharacterCount={100}
+ value={draft.monitor.description || ''}
+ onChange={e => setDraft(prev => ({ ...prev, monitor: { ...prev.monitor, description: e.target.value } }))}
+ />
+ </HzMonitorEditorSection>
+
+ {draft.monitor.app === 'prometheus' ? (
+ <HzMonitorEditorSection data-monitor-editor-section-owner="hertzbeat-ui-editor-section" title={t('monitor.grafana.enabled.label')}>
+ <HzCheckbox
+ containerClassName="mb-3"
+ data-monitor-grafana-enabled-checkbox="hertzbeat-ui-checkbox"
+ data-monitor-editor-checkbox="grafana-dashboard-enabled"
+ data-monitor-editor-checkbox-owner="hertzbeat-ui-checkbox"
+ checked={Boolean(draft.grafanaDashboard.enabled)}
+ onChange={e => setDraft(prev => ({ ...prev, grafanaDashboard: { ...prev.grafanaDashboard, enabled: e.target.checked } }))}
+ label={t('monitor.grafana.enabled.label')}
+ />
+ {draft.grafanaDashboard.enabled ? (
+ <div
+ className="space-y-3"
+ data-monitor-grafana-template-input-contract="angular-json-template-upload"
+ >
+ <div
+ className="flex flex-wrap items-center gap-2"
+ data-monitor-grafana-template-upload="angular-json-template-upload"
+ >
+ <HzFileInput
+ ref={grafanaTemplateInputRef}
+ accept=".json,application/json"
+ aria-label={t('monitor.grafana.upload.label')}
+ data-monitor-grafana-template-upload-owner="hertzbeat-ui-file-input"
+ data-monitor-grafana-template-upload-input="json-template"
+ onChange={event => void handleGrafanaTemplateUpload(event)}
/>
- ) : null}
- </SurfaceSection>
+ <HzButton
+ type="button"
+ size="sm"
+ intent="secondary"
+ data-monitor-grafana-template-upload-trigger-owner="hertzbeat-ui-button"
+ data-monitor-grafana-template-upload-trigger="json-template"
+ onClick={() => grafanaTemplateInputRef.current?.click()}
+ >
+ {t('monitor.grafana.upload.label')}
+ </HzButton>
+ <span className="text-xs text-[var(--hz-ui-text-muted)]">
+ {t('monitor.grafana.upload.tip')}
+ </span>
+ </div>
+ <HzCodeEditor
+ name="grafana_dashboard_template"
+ value={grafanaTemplateText}
+ language="json"
+ minHeight="120px"
+ ariaLabel={t('monitor.grafana.enabled.label')}
+ data-monitor-editor-code-editor-owner="hertzbeat-ui-code-editor"
+ data-monitor-editor-code-editor="grafana-dashboard-template"
+ data-monitor-grafana-code-editor="dashboard-template"
+ onChange={setGrafanaTemplateText}
+ />
+ </div>
) : null}
- <SurfaceSection title={t('monitor.editor.section.payload')} copy={t('monitor.editor.section.payload.copy')}>
- <RowList
- rows={[
- { title: t('common.app'), copy: draft.monitor.app || '-', meta: draft.monitor.scrape || 'static' },
- { title: t('monitor.name'), copy: draft.monitor.name || '-', meta: draft.collector || '-' }
- ]}
- />
- </SurfaceSection>
- {actionMessage ? <div className="text-sm text-emerald-300">{actionMessage}</div> : null}
- {actionError ? <div className="text-sm text-rose-300">{actionError}</div> : null}
- </>
- }
- />
- </form>
+ </HzMonitorEditorSection>
+ ) : null}
+ </HzMonitorEditorForm>
);
}
diff --git a/web-next/lib/monitor-api-facade.test.ts b/web-next/lib/monitor-api-facade.test.ts
new file mode 100644
index 0000000000..572733772c
--- /dev/null
+++ b/web-next/lib/monitor-api-facade.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { api } from './monitor-api-facade';
+
+const fetchMock = vi.fn<typeof fetch>();
+
+function mockApiMessagePayload(data: unknown) {
+ fetchMock.mockResolvedValueOnce(
+ new Response(JSON.stringify({ code: 0, data }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+ );
+}
+
+describe('monitor api facade', () => {
+ afterEach(() => {
+ fetchMock.mockReset();
+ vi.unstubAllGlobals();
+ });
+
+ it('maps monitor list reads through the monitor domain URL builder', async () => {
+ vi.stubGlobal('fetch', fetchMock);
+ mockApiMessagePayload({ content: [{ id: 42 }], totalElements: 1 });
+
+ await expect(
+ api.monitors.page({
+ search: ' mysql ',
+ app: 'mysql',
+ labels: '',
+ status: '2',
+ pageIndex: '0',
+ pageSize: '8',
+ entityId: '',
+ entityName: '',
+ returnTo: ''
+ })
+ ).resolves.toEqual({ content: [{ id: 42 }], totalElements: 1 });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/monitors?pageIndex=0&pageSize=8&search=mysql&app=mysql&status=2',
+ expect.objectContaining({ credentials: 'same-origin', cache: 'no-store' })
+ );
+ });
+
+ it('maps monitor editor draft reads through the monitor domain facade', async () => {
+ vi.stubGlobal('fetch', fetchMock);
+ mockApiMessagePayload({ monitor: { id: 42, app: 'mysql' }, params: [], collector: null });
+ mockApiMessagePayload({ content: [{ collector: { name: 'edge-a' } }] });
+ mockApiMessagePayload([{ field: 'host', type: 'text' }]);
+
+ await expect(api.monitors.editorDetail(42)).resolves.toEqual({
+ monitor: { id: 42, app: 'mysql' },
+ params: [],
+ collector: null
+ });
+ await expect(api.monitors.editorCollectors()).resolves.toEqual({ content: [{ collector: { name: 'edge-a' } }] });
+ await expect(api.monitors.editorParamDefines('mysql')).resolves.toEqual([{ field: 'host', type: 'text' }]);
+
+ expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/monitor/42', expect.objectContaining({ credentials: 'same-origin' }));
+ expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/collector', expect.objectContaining({ credentials: 'same-origin' }));
+ expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/apps/mysql/params', expect.objectContaining({ credentials: 'same-origin' }));
+ });
+
+ it('maps monitor export downloads to response readers', async () => {
+ vi.stubGlobal('fetch', fetchMock);
+ fetchMock.mockResolvedValueOnce(new Response('zip', { status: 200 }));
+
+ await expect(api.monitors.exportResponse([42, 43], 'JSON')).resolves.toBeInstanceOf(Response);
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/monitors/export?ids=42&ids=43&type=JSON',
+ expect.objectContaining({ credentials: 'same-origin', cache: 'no-store' })
+ );
+ });
+});
diff --git a/web-next/lib/monitor-api-facade.ts b/web-next/lib/monitor-api-facade.ts
new file mode 100644
index 0000000000..6a8db74281
--- /dev/null
+++ b/web-next/lib/monitor-api-facade.ts
@@ -0,0 +1,84 @@
+import { apiDownload, apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from './api-client';
+import {
+ buildMonitorDetailUrl,
+ buildMonitorFavoriteUrl,
+ buildMonitorGrafanaUrl,
+ buildMonitorHistoryCatalogUrl,
+ buildMonitorHistoryMetricDataUrl,
+ buildMonitorRealtimeMetricUrl,
+ type MonitorHistoryCatalogDefineResponse,
+ type MonitorHistoryMetricCatalogItem
+} from './monitor-detail/controller';
+import {
+ buildMonitorEditorCollectorsUrl,
+ buildMonitorEditorMonitorUrl,
+ buildMonitorEditorParamDefinesUrl,
+ type MonitorDetailResponse
+} from './monitor-editor/controller';
+import {
+ buildCopyMonitorUrl,
+ buildDeleteMonitorsUrl,
+ buildEnableMonitorsUrl,
+ buildExportAllMonitorsUrl,
+ buildExportMonitorsUrl,
+ buildImportMonitorsUrl,
+ buildPauseMonitorsUrl
+} from './monitor-manage/controller';
+import { buildMonitorUrl, type MonitorQueryState } from './monitor-manage/query-state';
+import type { CollectorSummary, Monitor, MonitorHistoryData, PageResult, ParamDefine } from './types';
+
+type MonitorListQuery = string | URLSearchParams | Record<string, string | number | boolean | undefined | null>;
+
+function appendQuery(path: string, query?: MonitorListQuery) {
+ if (!query) return path;
+ const params =
+ typeof query === 'string'
+ ? new URLSearchParams(query.startsWith('?') ? query.slice(1) : query)
+ : query instanceof URLSearchParams
+ ? query
+ : new URLSearchParams(
+ Object.entries(query).flatMap(([key, value]) => (value == null || value === '' ? [] : [[key, String(value)]]))
+ );
+ const queryString = params.toString();
+ return queryString ? `${path}?${queryString}` : path;
+}
+
+export const api = {
+ monitors: {
+ list: <T = PageResult<Monitor>>(query?: MonitorListQuery) => apiMessageGet<T>(appendQuery('/monitors/manage', query)),
+ page: <T = PageResult<Monitor>>(query: MonitorQueryState) => apiMessageGet<T>(buildMonitorUrl(query)),
+ detail: <T = Monitor>(monitorId: string | number) => apiMessageGet<T>(buildMonitorDetailUrl(String(monitorId))),
+ editorDetail: <T = MonitorDetailResponse>(monitorId: string | number) =>
+ apiMessageGet<T>(buildMonitorEditorMonitorUrl(String(monitorId))),
+ editorCollectors: <T = { content?: CollectorSummary[] }>() => apiMessageGet<T>(buildMonitorEditorCollectorsUrl()),
+ editorParamDefines: (app: string) => apiMessageGet<ParamDefine[]>(buildMonitorEditorParamDefinesUrl(app)),
+ favoriteMetrics: (monitorId: string | number) => apiMessageGet<string[]>(buildMonitorFavoriteUrl(String(monitorId))),
+ addFavoriteMetric: (monitorId: string | number, metricName: string) =>
+ apiMessagePost<unknown>(buildMonitorFavoriteUrl(String(monitorId), metricName), null),
+ removeFavoriteMetric: (monitorId: string | number, metricName: string) =>
+ apiMessageDelete<unknown>(buildMonitorFavoriteUrl(String(monitorId), metricName)),
+ grafanaDashboard: <T = unknown>(monitorId: string | number) => apiMessageGet<T>(buildMonitorGrafanaUrl(String(monitorId))),
+ deleteGrafanaDashboard: (monitorId: string | number) => apiMessageDelete<unknown>(buildMonitorGrafanaUrl(String(monitorId))),
+ realtimeMetric: <T = unknown>(monitorId: string | number, metricName: string) =>
+ apiMessageGet<T>(buildMonitorRealtimeMetricUrl(String(monitorId), metricName)),
+ warehouseStorageStatus: <T = unknown>() => apiMessageGet<T>('/warehouse/storage/status'),
+ historyMetricCatalogDefine: (monitor: Monitor) =>
+ apiMessageGet<MonitorHistoryCatalogDefineResponse>(buildMonitorHistoryCatalogUrl(monitor)),
+ historyMetric: (monitor: Monitor, item: MonitorHistoryMetricCatalogItem, query?: Parameters<typeof buildMonitorHistoryMetricDataUrl>[2]) =>
+ apiMessageGet<MonitorHistoryData>(buildMonitorHistoryMetricDataUrl(monitor, item, query)),
+ copy: (monitorId: string | number) => apiMessagePost<unknown>(buildCopyMonitorUrl(monitorId), null),
+ enable: (ids: Array<string | number>) => apiMessageGet<unknown>(buildEnableMonitorsUrl(ids)),
+ pause: (ids: Array<string | number>) => apiMessageDelete<unknown>(buildPauseMonitorsUrl(ids)),
+ delete: (ids: Array<string | number>) => apiMessageDelete<unknown>(buildDeleteMonitorsUrl(ids)),
+ appHierarchy: <T = unknown>(locale?: string | null) =>
+ apiMessageGet<T>(`/apps/hierarchy${locale ? `?lang=${encodeURIComponent(locale)}` : ''}`),
+ create: <T = unknown>(payload: unknown) => apiMessagePost<T>('/monitor', payload),
+ update: <T = unknown>(payload: unknown) => apiMessagePut<T>('/monitor', payload),
+ detect: <T = unknown>(payload: unknown) => apiMessagePost<T>('/monitor/detect', payload),
+ import: <T = unknown>(body: FormData) => apiMessagePost<T>(buildImportMonitorsUrl(), body),
+ export: (ids: Array<string | number>, type: 'JSON' | 'EXCEL') => apiMessageGet<Blob>(buildExportMonitorsUrl(ids, type)),
+ exportAll: (type: 'JSON' | 'EXCEL') => apiMessageGet<Blob>(buildExportAllMonitorsUrl(type)),
+ exportResponse: (ids: Array<string | number>, type: 'JSON' | 'EXCEL') => apiDownload(buildExportMonitorsUrl(ids, type)),
+ exportAllResponse: (type: 'JSON' | 'EXCEL') => apiDownload(buildExportAllMonitorsUrl(type))
+ }
+};
diff --git a/web-next/lib/monitor-editor/controller.test.ts b/web-next/lib/monitor-editor/controller.test.ts
index d26ce61851..18166cf70e 100644
--- a/web-next/lib/monitor-editor/controller.test.ts
+++ b/web-next/lib/monitor-editor/controller.test.ts
@@ -1,20 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { createTranslatorMock } from '../../test/i18n-test-helper';
import {
+ applyMonitorHostNameAutofill,
+ buildMonitorEditCacheKey,
+ buildMonitorEditorCollectorsUrl,
+ buildMonitorEditorMonitorUrl,
+ buildMonitorEditorParamDefinesUrl,
buildMonitorDetectPayload,
buildMonitorSavePayload,
createMonitor,
+ createMonitorFromFacade,
detectMonitor,
+ detectMonitorFromFacade,
+ generateMonitorEditorReadableName,
isValidMonitorCronExpression,
loadMonitorEditorDraft,
+ loadMonitorEditorDraftFromFacade,
loadMonitorScrapeDraft,
+ loadMonitorScrapeDraftFromFacade,
+ resolveMonitorEditorParamChangeNotice,
+ shouldPreserveMonitorScrapeParamsForLoad,
syncMonitorDependentDisplay,
updateMonitor,
+ updateMonitorFromFacade,
updateMonitorEditorParam,
validateMonitorEditorDraft
} from './controller';
describe('monitor editor controller', () => {
+ it('exposes stable editor resource URLs for workbench cache keys', () => {
+ expect(buildMonitorEditorCollectorsUrl()).toBe('/collector');
+ expect(buildMonitorEditorMonitorUrl('42')).toBe('/monitor/42');
+ expect(buildMonitorEditorParamDefinesUrl('website')).toBe('/apps/website/params');
+ expect(buildMonitorEditCacheKey(42)).toBe('monitor-editor-edit:/monitor/42:/collector');
+ });
+
it('loads new monitor draft from app params and collectors', async () => {
const apiGet = vi.fn()
.mockResolvedValueOnce({ content: [{ collector: { name: 'edge-a' } }] })
@@ -23,11 +43,86 @@ describe('monitor editor controller', () => {
await expect(loadMonitorEditorDraft(apiGet as any, 'new', { app: 'website' })).resolves.toMatchObject({
monitor: { app: 'website', scrape: 'static', scheduleType: 'interval', intervals: 60 },
- collectors: ['edge-a'],
+ collectors: [{ name: 'edge-a' }],
params: [{ field: 'host', paramValue: '127.0.0.1' }]
});
});
+ it('loads monitor editor drafts from facade readers without exposing raw URLs to routes', async () => {
+ const readMonitorDetail = vi.fn(async () => ({
+ monitor: {
+ id: 42,
+ app: 'mysql',
+ name: 'mysql-prod',
+ instance: '127.0.0.1:3306',
+ scrape: 'static',
+ status: 2
+ },
+ params: [{ id: 25, field: 'host', paramValue: '127.0.0.1' }],
+ collector: 'edge-a',
+ grafanaDashboard: { enabled: true }
+ }));
+ const readCollectors = vi.fn(async () => ({
+ content: [
+ { collector: { name: 'main-default-collector' } },
+ { collector: { name: 'edge-a', ip: '10.0.0.12', status: 0, mode: 'private' } }
+ ]
+ }));
+ const readParamDefines = vi.fn(async (app: string) => (app === 'mysql' ? [{ field: 'host', type: 'text', defaultValue: '' }] : []));
+
+ await expect(
+ loadMonitorEditorDraftFromFacade(
+ {
+ readMonitorDetail,
+ readCollectors,
+ readParamDefines
+ },
+ 'edit',
+ { monitorId: '42' }
+ )
+ ).resolves.toMatchObject({
+ monitor: { id: 42, app: 'mysql', scrape: 'static', scheduleType: 'interval', intervals: 60 },
+ collector: 'edge-a',
+ collectors: [{ name: 'edge-a', ip: '10.0.0.12', status: 0, mode: 'private' }],
+ params: [{ id: 25, field: 'host', paramValue: '127.0.0.1' }],
+ grafanaDashboard: { enabled: true }
+ });
+
+ expect(readMonitorDetail).toHaveBeenCalledWith('42');
+ expect(readCollectors).toHaveBeenCalledTimes(1);
+ expect(readParamDefines).toHaveBeenCalledWith('mysql');
+ });
+
+ it('maps the built-in main collector to system default dispatch instead of a pinned collector', async () => {
+ const apiGet = vi.fn()
+ .mockResolvedValueOnce({
+ monitor: {
+ id: 42,
+ app: 'mysql',
+ name: 'mysql-prod',
+ instance: '127.0.0.1:3306',
+ scrape: 'static',
+ status: 2
+ },
+ collector: 'main-default-collector',
+ params: [{ id: 25, field: 'host', paramValue: '127.0.0.1' }]
+ })
+ .mockResolvedValueOnce({
+ content: [
+ { collector: { name: 'main-default-collector' } },
+ { collector: { name: 'edge-a' } }
+ ]
+ })
+ .mockResolvedValueOnce([{ field: 'host', type: 'text', defaultValue: '' }])
+ .mockResolvedValueOnce([]);
+
+ await expect(loadMonitorEditorDraft(apiGet as any, 'edit', { monitorId: '42' })).resolves.toMatchObject({
+ collector: '',
+ collectors: [{ name: 'edge-a' }],
+ params: [{ id: 25, field: 'host', paramValue: '127.0.0.1' }]
+ });
+ });
+
it('builds save/detect payloads and posts them', async () => {
const draft = {
monitor: { id: 1, app: 'website', name: 'demo', instance: '127.0.0.1', scrape: 'static', status: 0 },
@@ -61,6 +156,72 @@ describe('monitor editor controller', () => {
expect(apiPost).toHaveBeenCalledWith('/monitor/detect', buildMonitorDetectPayload(draft));
});
+ it('submits save and detect payloads through facade writers', async () => {
+ const draft = {
+ monitor: { id: 1, app: 'website', name: 'demo', instance: '127.0.0.1', scrape: 'static', status: 0 },
+ collector: 'edge-a',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: '127.0.0.1' }],
+ advancedParams: [],
+ scrapeParams: []
+ } as any;
+ const writeCreate = vi.fn().mockResolvedValue(undefined);
+ const writeUpdate = vi.fn().mockResolvedValue(undefined);
+ const writeDetect = vi.fn().mockResolvedValue(undefined);
+
+ await createMonitorFromFacade(writeCreate, draft);
+ await updateMonitorFromFacade(writeUpdate, draft);
+ await detectMonitorFromFacade(writeDetect, draft);
+
+ expect(writeCreate).toHaveBeenCalledWith(buildMonitorSavePayload(draft));
+ expect(writeUpdate).toHaveBeenCalledWith(buildMonitorSavePayload(draft));
+ expect(writeDetect).toHaveBeenCalledWith(buildMonitorDetectPayload(draft));
+ });
+
+ it('matches Angular new-monitor host-change auto name behavior without touching edit mode', () => {
+ const draft = {
+ monitor: { id: 0, app: 'website', name: '', instance: '', scrape: 'static', status: 0 },
+ collector: '',
+ grafanaDashboard: { enabled: false },
+ params: [{ field: 'host', paramValue: 'example.com' }],
+ paramDefines: [{ field: 'host', type: 'text' }],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: [],
+ collectors: []
+ } as any;
+
+ expect(
+ applyMonitorHostNameAutofill(draft, {
+ mode: 'new',
+ field: 'host',
+ generateName: () => 'Bright_Probe_23AB'
+ }).monitor.name
+ ).toBe('Bright_Probe_23AB');
+
+ expect(
+ applyMonitorHostNameAutofill(
+ { ...draft, monitor: { ...draft.monitor, name: 'operator-name' } },
+ {
+ mode: 'new',
+ field: 'host',
+ generateName: () => 'Bright_Probe_23AB'
+ }
+ ).monitor.name
+ ).toBe('operator-name');
+
+ expect(
+ applyMonitorHostNameAutofill(draft, {
+ mode: 'edit',
+ field: 'host',
+ generateName: () => 'Bright_Probe_23AB'
+ }).monitor.name
+ ).toBe('');
+
+ expect(generateMonitorEditorReadableName(() => 0)).toBe('Quick_Probe_22AA');
+ });
+
it('preserves existing param ids in update payloads', () => {
const draft = {
monitor: { id: 42, app: 'website', name: 'demo', instance: 'example.com:80', scrape: 'static', status: 1 },
@@ -76,7 +237,7 @@ describe('monitor editor controller', () => {
expect(buildMonitorSavePayload(draft)).toEqual({
monitor: { ...draft.monitor, instance: 'example.com' },
- collector: '',
+ collector: null,
params: [
{ id: 1, field: 'host', paramValue: 'example.com', type: 0 },
{ id: 2, field: 'port', paramValue: '80', type: 0 }
@@ -93,6 +254,17 @@ describe('monitor editor controller', () => {
});
});
+ it('loads dynamic scrape draft params from a facade param-define reader', async () => {
+ const readParamDefines = vi.fn(async () => [{ field: 'service', type: 'text', defaultValue: 'catalog' }]);
+
+ await expect(loadMonitorScrapeDraftFromFacade(readParamDefines, 'http_sd')).resolves.toEqual({
+ scrapeParams: [{ field: 'service', type: 1, paramValue: 'catalog' }],
+ scrapeParamDefines: [{ field: 'service', type: 'text', defaultValue: 'catalog' }]
+ });
+
+ expect(readParamDefines).toHaveBeenCalledWith('http_sd');
+ });
+
it('preserves existing scrape param values on reload', async () => {
const apiGet = vi.fn().mockResolvedValue([{ field: 'service', type: 'text', defaultValue: 'catalog' }]);
await expect(loadMonitorScrapeDraft(apiGet as any, 'http_sd', [{ field: 'service', paramValue: 'inventory' } as any])).resolves.toEqual({
@@ -101,6 +273,14 @@ describe('monitor editor controller', () => {
});
});
+ it('matches Angular scrape reload lifecycle by preserving only the initial edit load', () => {
+ expect(shouldPreserveMonitorScrapeParamsForLoad(null, 'http_sd')).toBe(true);
+ expect(shouldPreserveMonitorScrapeParamsForLoad(undefined, 'nacos_sd')).toBe(true);
+ expect(shouldPreserveMonitorScrapeParamsForLoad(null, 'static')).toBe(false);
+ expect(shouldPreserveMonitorScrapeParamsForLoad('http_sd', 'dns_sd')).toBe(false);
+ expect(shouldPreserveMonitorScrapeParamsForLoad('static', 'http_sd')).toBe(false);
+ });
+
it('syncs dependent visibility and boolean side effects', () => {
const draft = {
monitor: { app: 'api' },
@@ -126,6 +306,36 @@ describe('monitor editor controller', () => {
expect(next.params[2].display).toBe(true);
});
+ it('resolves Angular ssl port auto-change notification keys', () => {
+ const apiDraft = {
+ monitor: { app: 'api' },
+ params: [
+ { field: 'ssl', paramValue: false },
+ { field: 'port', paramValue: 80 }
+ ],
+ advancedParams: [],
+ scrapeParams: []
+ } as any;
+ const ftpDraft = {
+ ...apiDraft,
+ monitor: { app: 'ftp' },
+ params: [
+ { field: 'ssl', paramValue: false },
+ { field: 'port', paramValue: 21 }
+ ]
+ } as any;
+
+ expect(resolveMonitorEditorParamChangeNotice(apiDraft, 'params', 0, true)).toBe('monitor.new.notify.change-to-https');
+ expect(resolveMonitorEditorParamChangeNotice({ ...apiDraft, params: [{ field: 'ssl' }, { field: 'port', paramValue: 443 }] } as any, 'params', 0, false)).toBe(
+ 'monitor.new.notify.change-to-http'
+ );
+ expect(resolveMonitorEditorParamChangeNotice(ftpDraft, 'params', 0, true)).toBe('monitor.new.notify.change-to-sftp');
+ expect(resolveMonitorEditorParamChangeNotice({ ...ftpDraft, params: [{ field: 'ssl' }, { field: 'port', paramValue: 22 }] } as any, 'params', 0, false)).toBe(
+ 'monitor.new.notify.change-to-ftp'
+ );
+ expect(resolveMonitorEditorParamChangeNotice({ ...apiDraft, monitor: { app: 'website' } } as any, 'params', 0, true)).toBeNull();
+ });
+
it('trims monitor and param strings in save payloads', () => {
const draft = {
monitor: { id: 42, app: 'website', name: ' demo monitor ', instance: 'example.com:80', scrape: 'static', status: 1, description: ' desc ' },
@@ -141,7 +351,7 @@ describe('monitor editor controller', () => {
expect(buildMonitorSavePayload(draft)).toEqual({
monitor: { ...draft.monitor, name: 'demo monitor', description: 'desc', instance: 'example.com' },
- collector: '',
+ collector: null,
params: [
{ id: 1, field: 'host', paramValue: 'example.com', type: 1 },
{ id: 2, field: 'uri', paramValue: '/health', type: 1 }
@@ -150,6 +360,66 @@ describe('monitor editor controller', () => {
});
});
+ it('matches Angular detect/save payload shape for merged params, host instance, and Grafana', () => {
+ const draft = {
+ monitor: {
+ id: 42,
+ app: 'prometheus',
+ name: ' prometheus monitor ',
+ instance: 'prometheus:9090',
+ scrape: 'http_sd',
+ scheduleType: 'interval',
+ intervals: 60,
+ status: 1
+ },
+ collector: ' edge-a ',
+ grafanaDashboard: { enabled: true, template: '{"panels":[]}' },
+ params: [
+ { id: 1, field: 'host', paramValue: ' prometheus.local ', type: 1 },
+ { id: 2, field: 'port', paramValue: ' 9090 ', type: 1 }
+ ],
+ paramDefines: [
+ { field: 'host', type: 'text' },
+ { field: 'port', type: 'text' }
+ ],
+ advancedParams: [{ id: 3, field: 'timeout', paramValue: ' 3 ', type: 1 }],
+ advancedParamDefines: [{ field: 'timeout', type: 'text' }],
+ scrapeParams: [{ id: 4, field: 'sd_url', paramValue: ' https://sd.local/targets ', type: 1 }],
+ scrapeParamDefines: [{ field: 'sd_url', type: 'text' }]
+ } as any;
+
+ expect(buildMonitorDetectPayload(draft)).toEqual({
+ monitor: {
+ ...draft.monitor,
+ name: 'prometheus monitor',
+ instance: 'prometheus.local'
+ },
+ collector: 'edge-a',
+ params: [
+ { id: 1, field: 'host', paramValue: 'prometheus.local', type: 1 },
+ { id: 2, field: 'port', paramValue: '9090', type: 1 },
+ { id: 3, field: 'timeout', paramValue: '3', type: 1 },
+ { id: 4, field: 'sd_url', paramValue: 'https://sd.local/targets', type: 1 }
+ ]
+ });
+ expect(buildMonitorDetectPayload(draft)).not.toHaveProperty('grafanaDashboard');
+ expect(buildMonitorSavePayload(draft)).toEqual({
+ monitor: {
+ ...draft.monitor,
+ name: 'prometheus monitor',
+ instance: 'prometheus.local'
+ },
+ collector: 'edge-a',
+ params: [
+ { id: 1, field: 'host', paramValue: 'prometheus.local', type: 1 },
+ { id: 2, field: 'port', paramValue: '9090', type: 1 },
+ { id: 3, field: 'timeout', paramValue: '3', type: 1 },
+ { id: 4, field: 'sd_url', paramValue: 'https://sd.local/targets', type: 1 }
+ ],
+ grafanaDashboard: { enabled: true, template: '{"panels":[]}' }
+ });
+ });
+
it('validates required fields and cron expressions before detect/save', () => {
const t = createTranslatorMock();
const draft = {
@@ -184,6 +454,46 @@ describe('monitor editor controller', () => {
draft.params[0].paramValue = 'example.com';
expect(validateMonitorEditorDraft(draft, t)).toBe('Cron expression is invalid');
+ expect(validateMonitorEditorDraft(draft, t, { validateCronFormat: false })).toBeNull();
+
+ draft.monitor.cronExpression = ' ';
+ expect(validateMonitorEditorDraft(draft, t, { validateCronFormat: false })).toBe('Cron expression is invalid');
+ });
+
+ it('localizes monitor editor validation copy in zh-CN', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+ const draft = {
+ monitor: {
+ id: 0,
+ app: 'website',
+ name: ' ',
+ instance: '',
+ scrape: 'static',
+ status: 0,
+ scheduleType: 'cron',
+ cronExpression: 'bad cron'
+ },
+ params: [
+ { field: 'host', paramValue: ' ', display: true },
+ { field: 'port', paramValue: 80, display: true }
+ ],
+ paramDefines: [
+ { field: 'host', type: 'text', required: true, name: 'Host' },
+ { field: 'port', type: 'number', required: true, name: 'Port' }
+ ],
+ advancedParams: [],
+ advancedParamDefines: [],
+ scrapeParams: [],
+ scrapeParamDefines: []
+ } as any;
+
+ expect(validateMonitorEditorDraft(draft, t)).toBe('请输入监控名称');
+
+ draft.monitor.name = 'demo';
+ expect(validateMonitorEditorDraft(draft, t)).toBe('请填写 Host');
+
+ draft.params[0].paramValue = 'example.com';
+ expect(validateMonitorEditorDraft(draft, t)).toBe('Cron 表达式不合法');
});
it('accepts common five-field and six-field cron expressions', () => {
diff --git a/web-next/lib/monitor-editor/controller.ts b/web-next/lib/monitor-editor/controller.ts
index 782d5a7654..8d7d8f369a 100644
--- a/web-next/lib/monitor-editor/controller.ts
+++ b/web-next/lib/monitor-editor/controller.ts
@@ -16,7 +16,7 @@ export type MonitorEditorDraft = {
advancedParamDefines: ParamDefine[];
scrapeParams: Param[];
scrapeParamDefines: ParamDefine[];
- collectors: string[];
+ collectors: Array<MonitorCollectorOption | string>;
};
export type MonitorScrapeDraft = {
@@ -24,16 +24,86 @@ export type MonitorScrapeDraft = {
scrapeParamDefines: ParamDefine[];
};
+export type MonitorCollectorOption = {
+ name: string;
+ ip?: string;
+ status?: string | number;
+ online?: boolean;
+ mode?: string | null;
+};
+
type ParamCollectionKind = 'params' | 'advancedParams' | 'scrapeParams';
type Translator = (key: string, params?: Record<string, string | number | null | undefined>) => string;
-type MonitorDetailResponse = {
+export type MonitorDetailResponse = {
monitor: Monitor;
params?: Param[];
collector?: string | null;
grafanaDashboard?: GrafanaDashboard;
};
+export type MonitorEditorDraftReaders = {
+ readMonitorDetail?: (monitorId: string) => Promise<MonitorDetailResponse>;
+ readCollectors: () => Promise<{ content?: CollectorSummary[] }>;
+ readParamDefines: (app: string) => Promise<ParamDefine[]>;
+};
+
+const SYSTEM_DEFAULT_COLLECTOR = 'main-default-collector';
+const MONITOR_NAME_ADJECTIVES = ['quick', 'bright', 'calm', 'brave', 'cool', 'eager', 'gentle', 'lively'];
+const MONITOR_NAME_NOUNS = ['probe', 'service', 'signal', 'watch', 'beacon', 'node', 'pulse', 'guard'];
+const MONITOR_NAME_DIGITS = '23456789';
+const MONITOR_NAME_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz';
+
+function isSystemDefaultCollector(value?: string | null) {
+ return value === SYSTEM_DEFAULT_COLLECTOR;
+}
+
+function normalizeCollectorForDraft(value?: string | null) {
+ return isSystemDefaultCollector(value) ? '' : value || '';
+}
+
+function normalizeCollectorForPayload(value?: string | null) {
+ const normalized = value?.trim();
+ return normalized && !isSystemDefaultCollector(normalized) ? normalized : null;
+}
+
+function pickRandomToken(values: string[], random: () => number) {
+ return values[Math.floor(random() * values.length)] || values[0];
+}
+
+function capitalizeMonitorNamePart(value: string) {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+export function generateMonitorEditorReadableName(random: () => number = Math.random) {
+ const adjective = capitalizeMonitorNamePart(pickRandomToken(MONITOR_NAME_ADJECTIVES, random));
+ const noun = capitalizeMonitorNamePart(pickRandomToken(MONITOR_NAME_NOUNS, random));
+ const randomDigits = Array.from({ length: 2 }, () => MONITOR_NAME_DIGITS.charAt(Math.floor(random() * MONITOR_NAME_DIGITS.length))).join('');
+ const randomChars = Array.from({ length: 2 }, () => MONITOR_NAME_CHARS.charAt(Math.floor(random() * MONITOR_NAME_CHARS.length))).join('');
+ return `${adjective}_${noun}_${randomDigits}${randomChars}`;
+}
+
+export function applyMonitorHostNameAutofill(
+ draft: MonitorEditorDraft,
+ {
+ mode,
+ field,
+ generateName = generateMonitorEditorReadableName
+ }: { mode: MonitorEditorMode; field?: string; generateName?: () => string }
+) {
+ if (mode !== 'new' || field !== 'host' || draft.monitor.name?.trim()) {
+ return draft;
+ }
+
+ return {
+ ...draft,
+ monitor: {
+ ...draft.monitor,
+ name: generateName()
+ }
+ };
+}
+
function paramType(type?: string) {
if (type === 'number') return 0;
if (type === 'key-value') return 3;
@@ -126,7 +196,8 @@ export function isValidMonitorCronExpression(cronExpression: string): boolean {
return cronRegex.test(cronExpression);
}
-export function validateMonitorEditorDraft(draft: MonitorEditorDraft, t: Translator) {
+export function validateMonitorEditorDraft(draft: MonitorEditorDraft, t: Translator, options: { validateCronFormat?: boolean } = {}) {
+ const validateCronFormat = options.validateCronFormat !== false;
const normalizedMonitor = normalizeMonitorFields(draft.monitor);
if (!normalizedMonitor.app?.trim()) return t('monitor.editor.validation.app');
if (!normalizedMonitor.name?.trim()) return t('monitor.editor.validation.name');
@@ -151,7 +222,8 @@ export function validateMonitorEditorDraft(draft: MonitorEditorDraft, t: Transla
}
if (normalizedMonitor.scheduleType === 'cron') {
- if (!isValidMonitorCronExpression(normalizedMonitor.cronExpression || '')) {
+ const cronExpression = normalizedMonitor.cronExpression || '';
+ if (!cronExpression.trim() || (validateCronFormat && !isValidMonitorCronExpression(cronExpression))) {
return t('monitor.editor.validation.cron');
}
} else if (normalizedMonitor.intervals == null || !Number.isFinite(Number(normalizedMonitor.intervals)) || Number(normalizedMonitor.intervals) <= 0) {
@@ -195,23 +267,60 @@ function mergeParamDrafts(defines: ParamDefine[], paramMap: Map<string, Param>)
};
}
+export function buildMonitorEditorParamDefinesUrl(app: string) {
+ return `/apps/${app}/params`;
+}
+
+export function buildMonitorEditorCollectorsUrl() {
+ return '/collector';
+}
+
+export function buildMonitorEditCacheKey(monitorId: number | string) {
+ return ['monitor-editor-edit', buildMonitorEditorMonitorUrl(String(monitorId)), buildMonitorEditorCollectorsUrl()].join(':');
+}
+
+export function buildMonitorEditorMonitorUrl(monitorId: string) {
+ return `/monitor/${monitorId}`;
+}
+
async function loadParamDefines(apiGet: ApiGetter, app: string) {
- return apiGet<ParamDefine[]>(`/apps/${app}/params`);
+ return apiGet<ParamDefine[]>(buildMonitorEditorParamDefinesUrl(app));
}
async function loadCollectors(apiGet: ApiGetter) {
- const response = await apiGet<{ content?: CollectorSummary[] }>('/collector');
- return (response.content || []).map(item => item.collector?.name).filter((value): value is string => Boolean(value));
+ const response = await apiGet<{ content?: CollectorSummary[] }>(buildMonitorEditorCollectorsUrl());
+ return normalizeCollectorSummaries(response);
+}
+
+function normalizeCollectorSummaries(response: { content?: CollectorSummary[] }) {
+ return (response.content || [])
+ .map(item => item.collector)
+ .filter((collector): collector is NonNullable<CollectorSummary['collector']> => Boolean(collector?.name) && !isSystemDefaultCollector(collector.name))
+ .map(collector => ({
+ name: String(collector.name),
+ ip: collector.ip,
+ status: collector.status,
+ online: collector.online,
+ mode: collector.mode
+ }));
}
export async function loadMonitorScrapeDraft(apiGet: ApiGetter, scrape?: string | null, existingParams?: Param[]) {
+ return loadMonitorScrapeDraftFromFacade(app => loadParamDefines(apiGet, app), scrape, existingParams);
+}
+
+export async function loadMonitorScrapeDraftFromFacade(
+ readParamDefines: (app: string) => Promise<ParamDefine[]>,
+ scrape?: string | null,
+ existingParams?: Param[]
+) {
if (!scrape || scrape === 'static') {
return {
scrapeParams: [],
scrapeParamDefines: []
} satisfies MonitorScrapeDraft;
}
- const defines = await loadParamDefines(apiGet, scrape);
+ const defines = await readParamDefines(scrape);
const paramMap = new Map((existingParams || []).map(param => [String(param.field), param]));
const merged = mergeParamDrafts(defines, paramMap);
return {
@@ -220,6 +329,10 @@ export async function loadMonitorScrapeDraft(apiGet: ApiGetter, scrape?: string
} satisfies MonitorScrapeDraft;
}
+export function shouldPreserveMonitorScrapeParamsForLoad(previousScrape: string | null | undefined, nextScrape: string) {
+ return !previousScrape && nextScrape !== 'static';
+}
+
function syncDependentCollection(defines: ParamDefine[], params: Param[], fieldValues: Map<string, unknown>) {
return params.map((param, index) => {
const define = defines[index];
@@ -270,7 +383,46 @@ export function updateMonitorEditorParam(draft: MonitorEditorDraft, kind: ParamC
return syncMonitorDependentDisplay(nextDraft);
}
+export function resolveMonitorEditorParamChangeNotice(draft: MonitorEditorDraft, kind: ParamCollectionKind, index: number, value: unknown) {
+ const changedField = String(draft[kind][index]?.field || '');
+ if (changedField !== 'ssl' || typeof value !== 'boolean') {
+ return null;
+ }
+
+ const portParam = draft.params.find(param => param.field === 'port');
+ if (!portParam) {
+ return null;
+ }
+
+ const current = Number(portParam.paramValue);
+ if (draft.monitor.app === 'api') {
+ if (value && (!Number.isFinite(current) || current === 80)) return 'monitor.new.notify.change-to-https';
+ if (!value && (!Number.isFinite(current) || current === 443)) return 'monitor.new.notify.change-to-http';
+ }
+ if (draft.monitor.app === 'ftp') {
+ if (value && (!Number.isFinite(current) || current === 21)) return 'monitor.new.notify.change-to-sftp';
+ if (!value && (!Number.isFinite(current) || current === 22)) return 'monitor.new.notify.change-to-ftp';
+ }
+ return null;
+}
+
export async function loadMonitorEditorDraft(apiGet: ApiGetter, mode: MonitorEditorMode, options: { app?: string; monitorId?: string }) {
+ return loadMonitorEditorDraftFromFacade(
+ {
+ readMonitorDetail: monitorId => apiGet<MonitorDetailResponse>(buildMonitorEditorMonitorUrl(monitorId)),
+ readCollectors: () => apiGet<{ content?: CollectorSummary[] }>(buildMonitorEditorCollectorsUrl()),
+ readParamDefines: app => loadParamDefines(apiGet, app)
+ },
+ mode,
+ options
+ );
+}
+
+export async function loadMonitorEditorDraftFromFacade(
+ readers: MonitorEditorDraftReaders,
+ mode: MonitorEditorMode,
+ options: { app?: string; monitorId?: string }
+) {
const baseMonitor: Monitor = {
id: options.monitorId ? Number(options.monitorId) : 0,
app: options.app || 'website',
@@ -290,7 +442,10 @@ export async function loadMonitorEditorDraft(apiGet: ApiGetter, mode: MonitorEdi
let grafanaDashboard: GrafanaDashboard = { enabled: false };
if (mode === 'edit' && options.monitorId) {
- const detail = await apiGet<MonitorDetailResponse>(`/monitor/${options.monitorId}`);
+ if (!readers.readMonitorDetail) {
+ throw new Error('Monitor detail reader is required for edit drafts');
+ }
+ const detail = await readers.readMonitorDetail(options.monitorId);
monitor = {
...baseMonitor,
...detail.monitor,
@@ -301,14 +456,14 @@ export async function loadMonitorEditorDraft(apiGet: ApiGetter, mode: MonitorEdi
annotations: detail.monitor.annotations || {}
};
paramMap = new Map((detail.params || []).map(param => [String(param.field), param]));
- collector = detail.collector || '';
+ collector = normalizeCollectorForDraft(detail.collector);
grafanaDashboard = detail.grafanaDashboard || { enabled: false };
}
const [collectors, mainDefines, scrapeDefines] = await Promise.all([
- loadCollectors(apiGet),
- loadParamDefines(apiGet, monitor.app),
- monitor.scrape && monitor.scrape !== 'static' ? loadParamDefines(apiGet, monitor.scrape) : Promise.resolve<ParamDefine[]>([])
+ readers.readCollectors().then(normalizeCollectorSummaries),
+ readers.readParamDefines(monitor.app),
+ monitor.scrape && monitor.scrape !== 'static' ? readers.readParamDefines(monitor.scrape) : Promise.resolve<ParamDefine[]>([])
]);
const mergedMain = mergeParamDrafts(mainDefines, paramMap);
@@ -336,7 +491,7 @@ export function buildMonitorSavePayload(draft: MonitorEditorDraft) {
];
return {
monitor: normalizeMonitorInstanceForSave(normalizeMonitorFields(draft.monitor), params),
- collector: draft.collector,
+ collector: normalizeCollectorForPayload(draft.collector),
params,
grafanaDashboard: draft.grafanaDashboard
};
@@ -350,7 +505,7 @@ export function buildMonitorDetectPayload(draft: MonitorEditorDraft) {
];
return {
monitor: normalizeMonitorInstanceForSave(normalizeMonitorFields(draft.monitor), params),
- collector: draft.collector,
+ collector: normalizeCollectorForPayload(draft.collector),
params
};
}
@@ -359,10 +514,22 @@ export async function createMonitor(apiPost: ApiPoster, draft: MonitorEditorDraf
return apiPost<unknown>('/monitor', buildMonitorSavePayload(draft));
}
+export async function createMonitorFromFacade(writeCreateMonitor: (payload: unknown) => Promise<unknown>, draft: MonitorEditorDraft) {
+ return writeCreateMonitor(buildMonitorSavePayload(draft));
+}
+
export async function updateMonitor(apiPut: ApiPutter, draft: MonitorEditorDraft) {
return apiPut<unknown>('/monitor', buildMonitorSavePayload(draft));
}
+export async function updateMonitorFromFacade(writeUpdateMonitor: (payload: unknown) => Promise<unknown>, draft: MonitorEditorDraft) {
+ return writeUpdateMonitor(buildMonitorSavePayload(draft));
+}
+
export async function detectMonitor(apiPost: ApiPoster, draft: MonitorEditorDraft) {
return apiPost<unknown>('/monitor/detect', buildMonitorDetectPayload(draft));
}
+
+export async function detectMonitorFromFacade(writeDetectMonitor: (payload: unknown) => Promise<unknown>, draft: MonitorEditorDraft) {
+ return writeDetectMonitor(buildMonitorDetectPayload(draft));
+}
diff --git a/web-next/lib/monitor-editor/navigation.test.ts b/web-next/lib/monitor-editor/navigation.test.ts
index 129460febf..38528d8b67 100644
--- a/web-next/lib/monitor-editor/navigation.test.ts
+++ b/web-next/lib/monitor-editor/navigation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { buildMonitorEditorReturnUrl } from './navigation';
+import { buildMonitorEditorCancelUrl, buildMonitorEditorReturnUrl } from './navigation';
describe('monitor editor navigation', () => {
it('builds the filtered return path when app is present', () => {
@@ -26,4 +26,11 @@ describe('monitor editor navigation', () => {
'/monitors?app=website&labels=team%3Dplatform&pageIndex=1&pageSize=20&entityId=42&entityName=Checkout+Service'
);
});
+
+ it('keeps Angular-style cancel navigation on the monitor list root unless a safe return target exists', () => {
+ expect(buildMonitorEditorCancelUrl()).toBe('/monitors');
+ expect(buildMonitorEditorCancelUrl({ labels: 'team=platform', pageIndex: '2' })).toBe('/monitors');
+ expect(buildMonitorEditorCancelUrl({ returnTo: '/entities/42?returnLabel=Checkout' })).toBe('/entities/42');
+ expect(buildMonitorEditorCancelUrl({ returnTo: '//evil.example', labels: 'team=platform' })).toBe('/monitors');
+ });
});
diff --git a/web-next/lib/monitor-editor/navigation.ts b/web-next/lib/monitor-editor/navigation.ts
index 6c158b5c49..aed5932e31 100644
--- a/web-next/lib/monitor-editor/navigation.ts
+++ b/web-next/lib/monitor-editor/navigation.ts
@@ -8,3 +8,7 @@ export function buildMonitorEditorReturnUrl(app?: string | null, returnContext?:
app
});
}
+
+export function buildMonitorEditorCancelUrl(returnContext?: MonitorEditorReturnContext | null) {
+ return buildMonitorListReturnHref(returnContext?.returnTo ? { returnTo: returnContext.returnTo } : undefined);
+}
diff --git a/web-next/lib/monitor-editor/query-state.test.ts b/web-next/lib/monitor-editor/query-state.test.ts
new file mode 100644
index 0000000000..63cafe58ae
--- /dev/null
+++ b/web-next/lib/monitor-editor/query-state.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildMonitorNewDefaultAppRedirectUrl,
+ hasMonitorNewAppParam,
+ readMonitorEditRouteState,
+ readMonitorNewRouteState
+} from './query-state';
+
+describe('monitor editor query state', () => {
+ it('normalizes multi-value URL search params into the first monitor new setup context value', () => {
+ expect(
+ readMonitorNewRouteState({
+ app: ['prometheus', 'website'],
+ labels: ['team=platform', 'team=ops'],
+ pageIndex: ['2', '0'],
+ pageSize: ['20', '8'],
+ entityId: ['42', '99'],
+ entityName: ['Checkout Service', 'Ignored Service'],
+ timeRange: ['custom', '1h'],
+ start: ['1715200000000', 'now-1h'],
+ end: ['1715203600000', 'now'],
+ refresh: ['30s', 'off'],
+ live: ['true', 'false'],
+ tz: ['UTC', 'Asia/Shanghai'],
+ returnTo: ['/entities/42?returnLabel=Checkout', '//evil.example']
+ })
+ ).toEqual({
+ app: 'prometheus',
+ returnContext: {
+ labels: 'team=platform',
+ pageIndex: '2',
+ pageSize: '20',
+ entityId: '42',
+ entityName: 'Checkout Service',
+ timeRange: 'custom',
+ start: '1715200000000',
+ end: '1715203600000',
+ refresh: '30s',
+ live: 'true',
+ tz: 'UTC',
+ returnTo: '/entities/42'
+ }
+ });
+ });
+
+ it('defaults to the website monitor type when setup route query values are absent', () => {
+ expect(readMonitorNewRouteState()).toEqual({
+ app: 'website',
+ returnContext: {
+ labels: null,
+ pageIndex: null,
+ pageSize: null,
+ entityId: null,
+ entityName: null,
+ timeRange: null,
+ start: null,
+ end: null,
+ refresh: null,
+ live: null,
+ tz: null,
+ returnTo: null
+ }
+ });
+ });
+
+ it('identifies whether the monitor new route already carries the Angular app selector', () => {
+ expect(hasMonitorNewAppParam({ app: 'website' })).toBe(true);
+ expect(hasMonitorNewAppParam({ app: ['prometheus', 'website'] })).toBe(true);
+ expect(hasMonitorNewAppParam()).toBe(false);
+ expect(hasMonitorNewAppParam({ app: '' })).toBe(false);
+ expect(buildMonitorNewDefaultAppRedirectUrl()).toBe('/monitors/new?app=website');
+ });
+
+ it('normalizes multi-value URL search params into the first monitor edit return context value', () => {
+ expect(
+ readMonitorEditRouteState({
+ labels: ['region=us-east', 'region=eu-west'],
+ pageIndex: ['3', '0'],
+ pageSize: ['50', '8'],
+ entityId: ['101', '202'],
+ entityName: ['Payment Gateway', 'Ignored Gateway'],
+ timeRange: ['custom', '6h'],
+ start: ['1715200000000', 'now-6h'],
+ end: ['1715203600000', 'now'],
+ refresh: ['60s', 'off'],
+ live: ['false', 'true'],
+ tz: ['Asia/Shanghai', 'UTC'],
+ returnTo: ['/monitors?app=website&returnLabel=Monitors', '//evil.example']
+ })
+ ).toEqual({
+ returnContext: {
+ labels: 'region=us-east',
+ pageIndex: '3',
+ pageSize: '50',
+ entityId: '101',
+ entityName: 'Payment Gateway',
+ timeRange: 'custom',
+ start: '1715200000000',
+ end: '1715203600000',
+ refresh: '60s',
+ live: 'false',
+ tz: 'Asia/Shanghai',
+ returnTo: '/monitors?app=website'
+ }
+ });
+ });
+});
diff --git a/web-next/lib/monitor-editor/query-state.ts b/web-next/lib/monitor-editor/query-state.ts
new file mode 100644
index 0000000000..643cd10276
--- /dev/null
+++ b/web-next/lib/monitor-editor/query-state.ts
@@ -0,0 +1,57 @@
+import { createCompatSearchParamReader, type SearchParamsRecord } from '../compat/search-params';
+import type { MonitorEditorReturnContext } from './navigation';
+
+export type MonitorNewSearchParams = SearchParamsRecord;
+export type MonitorEditSearchParams = SearchParamsRecord;
+
+export type MonitorNewRouteState = {
+ app: string;
+ returnContext: MonitorEditorReturnContext;
+};
+
+export type MonitorEditRouteState = {
+ returnContext: MonitorEditorReturnContext;
+};
+
+function readMonitorEditorReturnContext(searchParams: SearchParamsRecord = {}): MonitorEditorReturnContext {
+ const reader = createCompatSearchParamReader(searchParams);
+
+ return {
+ labels: reader.get('labels'),
+ pageIndex: reader.get('pageIndex'),
+ pageSize: reader.get('pageSize'),
+ entityId: reader.get('entityId'),
+ entityName: reader.get('entityName'),
+ timeRange: reader.get('timeRange'),
+ start: reader.get('start'),
+ end: reader.get('end'),
+ refresh: reader.get('refresh'),
+ live: reader.get('live'),
+ tz: reader.get('tz'),
+ returnTo: reader.get('returnTo')
+ };
+}
+
+export function readMonitorNewRouteState(searchParams: MonitorNewSearchParams = {}): MonitorNewRouteState {
+ const reader = createCompatSearchParamReader(searchParams);
+
+ return {
+ app: reader.get('app') || 'website',
+ returnContext: readMonitorEditorReturnContext(searchParams)
+ };
+}
+
+export function hasMonitorNewAppParam(searchParams: MonitorNewSearchParams = {}) {
+ const reader = createCompatSearchParamReader(searchParams);
+ return Boolean(reader.get('app'));
+}
+
+export function buildMonitorNewDefaultAppRedirectUrl() {
+ return '/monitors/new?app=website';
+}
+
+export function readMonitorEditRouteState(searchParams: MonitorEditSearchParams = {}): MonitorEditRouteState {
+ return {
+ returnContext: readMonitorEditorReturnContext(searchParams)
+ };
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org