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:21 UTC
(hertzbeat) 11/50: feat(web-next): restore collector plugin label 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 cae4d7576509ad76380796d6553f7915344aaac4
Author: Logic <zq...@dromara.org>
AuthorDate: Fri May 29 01:02:59 2026 +0800
feat(web-next): restore collector plugin label parity
---
web-next/app/setting/collector/page.test.tsx | 433 ++++++-
web-next/app/setting/collector/page.tsx | 44 +-
.../setting/collector/setting-collector-page.tsx | 340 ++++++
web-next/app/setting/define/page.test.tsx | 910 +++++++++++++-
web-next/app/setting/define/page.tsx | 153 +--
.../app/setting/define/setting-define-page.tsx | 257 ++++
web-next/app/setting/labels/page.test.tsx | 602 +++++++++-
web-next/app/setting/labels/page.tsx | 71 +-
.../app/setting/labels/setting-labels-page.tsx | 215 ++++
web-next/app/setting/plugins/page.test.tsx | 404 ++++++-
web-next/app/setting/plugins/page.tsx | 70 +-
.../app/setting/plugins/setting-plugins-page.tsx | 325 +++++
.../pages/collector-manage-surface.test.tsx | 830 ++++++++++++-
.../components/pages/collector-manage-surface.tsx | 645 +++++++++-
.../components/pages/label-manage-surface.test.tsx | 887 +++++++++++++-
web-next/components/pages/label-manage-surface.tsx | 493 ++++++--
.../pages/plugin-manage-surface.test.tsx | 1037 +++++++++++++++-
.../components/pages/plugin-manage-surface.tsx | 1253 +++++++++++++++++---
.../pages/setting-define-surface.test.tsx | 1151 ++++++++++++++----
.../components/pages/setting-define-surface.tsx | 751 ++++++++----
web-next/lib/collector-health-evidence.test.ts | 40 +-
web-next/lib/collector-health-evidence.ts | 33 +-
web-next/lib/collector-manage/controller.test.ts | 53 +-
web-next/lib/collector-manage/controller.ts | 81 ++
web-next/lib/collector-manage/query-state.test.ts | 12 +-
web-next/lib/collector-manage/query-state.ts | 6 +-
web-next/lib/collector-manage/view-model.test.ts | 29 +-
web-next/lib/collector-manage/view-model.ts | 32 +-
web-next/lib/label-manage/controller.test.ts | 87 +-
web-next/lib/label-manage/controller.ts | 26 +-
web-next/lib/label-manage/query-state.test.ts | 4 +-
web-next/lib/label-manage/query-state.ts | 6 +-
web-next/lib/label-manage/view-model.test.ts | 16 +-
web-next/lib/label-manage/view-model.ts | 25 +-
web-next/lib/plugin-manage/controller.test.ts | 128 +-
web-next/lib/plugin-manage/controller.ts | 140 ++-
web-next/lib/plugin-manage/query-state.test.ts | 6 +-
web-next/lib/plugin-manage/query-state.ts | 6 +-
web-next/lib/plugin-manage/view-model.test.ts | 33 +-
web-next/lib/plugin-manage/view-model.ts | 24 +-
web-next/lib/setting-define/controller.test.ts | 138 ++-
web-next/lib/setting-define/controller.ts | 156 ++-
web-next/lib/setting-define/query-state.test.ts | 25 +-
web-next/lib/setting-define/query-state.ts | 32 +-
web-next/lib/setting-define/view-model.test.ts | 186 ++-
web-next/lib/setting-define/view-model.ts | 126 +-
46 files changed, 10801 insertions(+), 1520 deletions(-)
diff --git a/web-next/app/setting/collector/page.test.tsx b/web-next/app/setting/collector/page.test.tsx
index d34c05efa6..9f2406a33a 100644
--- a/web-next/app/setting/collector/page.test.tsx
+++ b/web-next/app/setting/collector/page.test.tsx
@@ -1,12 +1,21 @@
+// @vitest-environment jsdom
+
import React from 'react';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTranslatorMock } from '../../../test/i18n-test-helper';
(globalThis as { React?: typeof React }).React = React;
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockState = vi.hoisted(() => ({
lastLoad: null as null | (() => Promise<unknown>),
+ lastSurfaceProps: null as null | Record<string, any>,
+ renderErrorMessage: null as string | null,
renderData: {
list: {
content: [
@@ -31,23 +40,20 @@ const mockState = vi.hoisted(() => ({
}));
const apiMessageGet = vi.hoisted(() => vi.fn());
+const apiMessageDelete = vi.hoisted(() => vi.fn());
+const apiMessagePost = vi.hoisted(() => vi.fn());
+const apiMessagePut = vi.hoisted(() => vi.fn());
const loadCollectorData = vi.hoisted(() => vi.fn());
+const buildCollectorDeployCommands = vi.hoisted(() => vi.fn());
+const deleteCollectors = vi.hoisted(() => vi.fn());
+const generateCollectorIdentity = vi.hoisted(() => vi.fn());
+const goOnlineCollectors = vi.hoisted(() => vi.fn());
+const goOfflineCollectors = vi.hoisted(() => vi.fn());
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
t: createTranslatorMock({
- locale: 'zh-CN',
- overrides: {
- 'menu.advanced.collector': '采集集群',
- 'collector.deploy': '部署采集器',
- 'collector.online': '上线采集器',
- 'collector.offline': '下线采集器',
- 'collector.delete': '删除采集器',
- 'collector.name': '采集器名称',
- 'collector.status': '运行状态',
- 'common.refresh': '刷新',
- 'common.search': '搜索'
- }
+ locale: 'zh-CN'
})
})
}));
@@ -55,37 +61,144 @@ vi.mock('@/components/providers/i18n-provider', () => ({
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
- load
+ load,
+ renderError
}: {
children: (data: unknown) => React.ReactNode;
load: () => Promise<unknown>;
+ renderError?: (message: string, retry: () => void) => React.ReactNode;
}) => {
mockState.lastLoad = load;
+ if (mockState.renderErrorMessage && renderError) {
+ return <div data-client-workbench="true">{renderError(mockState.renderErrorMessage, vi.fn())}</div>;
+ }
return <div data-client-workbench="true">{children(mockState.renderData)}</div>;
}
}));
vi.mock('@/components/pages/collector-manage-surface', () => ({
- CollectorManageSurface: ({ search, data }: any) => (
- <div data-collector-manage-surface="otlp-cold-collector-console" data-collector-manage-style-baseline="hertzbeat-cold-matte">
- <section data-collector-admin-layout="full-width-admin-list">
- <span>刷新</span>
- <span>部署采集器</span>
- <span>上线采集器</span>
- <span>下线采集器</span>
- <span>删除采集器</span>
- <span>搜索</span>
- <span>采集集群</span>
- <span>{search}</span>
- <span>{data.list.content[0].collector.name}</span>
- <span>{data.list.content[0].collector.ip}</span>
- </section>
- </div>
- )
+ CollectorManageSurface: (props: any) => {
+ mockState.lastSurfaceProps = props;
+ const {
+ search,
+ data,
+ selectedCollectors,
+ actionMessage,
+ actionError,
+ actionMeta,
+ actionTone,
+ actionFeedbackContract,
+ loadError,
+ deleteTarget,
+ isDeletePending,
+ isLoadPending,
+ deployOpen,
+ deployName,
+ deployIdentity,
+ deployError,
+ deployCopyMessage,
+ isDeployPending,
+ deployNameValidationVisible,
+ onDeploy,
+ onDeployNameChange,
+ onDeployGenerate,
+ onDeployClose
+ } = props;
+ return (
+ <div
+ data-collector-manage-surface="otlp-cold-collector-console"
+ data-collector-manage-style-baseline="hertzbeat-cold-matte"
+ data-collector-delete-warning-contract="angular-no-select-warning"
+ data-collector-delete-confirm-contract="angular-modal-confirm"
+ data-collector-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-collector-delete-feedback-contract="angular-delete-notify"
+ data-collector-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-collector-delete-api-contract="angular-repeated-collectors-query"
+ data-collector-delete-page-clamp-contract="angular-update-page-index"
+ data-collector-delete-page-clamp-owner="route-state-contract"
+ data-collector-search-submit-contract="angular-enter-and-clear"
+ data-collector-search-clear-contract="angular-cleared-load"
+ data-collector-search-clear-owner="shared-search-row"
+ data-collector-operate-warning-contract="angular-no-select-online-offline"
+ data-collector-operate-confirm-contract="angular-modal-confirm"
+ data-collector-operate-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-collector-operate-feedback-contract="angular-operate-notify"
+ data-collector-operate-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-collector-operate-api-contract="angular-put-repeated-collectors-query"
+ data-collector-mutation-loading-contract="angular-nz-table-loading"
+ data-collector-load-failure-contract="angular-console-only-shell"
+ data-collector-load-failure-owner="collector-route-controller"
+ data-collector-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-collector-table-loading-contract="angular-load-collectors-table"
+ data-collector-table-loading-owner="angular-nz-table-loading"
+ data-collector-table-loading-scope="load-search-refresh-pagination-mutation"
+ data-collector-table-loading-source={isLoadPending || isDeletePending ? 'route-load-or-mutation' : 'idle'}
+ data-collector-deploy-modal-contract="angular-generate-identity-modal"
+ data-collector-deploy-api-contract="angular-post-generate-identity"
+ data-collector-deploy-failure-contract="angular-apply-fail-notification"
+ data-collector-deploy-validation-contract="angular-submit-marks-required"
+ data-collector-deploy-validation-raw-contract="angular-required-before-trim"
+ data-collector-deploy-validation-raw-owner="collector-route-controller"
+ data-collector-deploy-loading-contract="angular-nz-ok-loading"
+ data-collector-deploy-mask-contract="angular-mask-closable-false"
+ data-collector-deploy-width-contract="angular-width-45-percent"
+ data-collector-deploy-field-layout-contract="angular-label-7-control-12"
+ data-collector-deploy-field-layout-owner="route-form-field-grid"
+ data-collector-deploy-command-contract="angular-docker-package-shells"
+ data-collector-deploy-command-owner="hertzbeat-ui-code-editor"
+ data-collector-deploy-package-link-contract="angular-github-releases-link"
+ data-collector-deploy-copy-contract="angular-copy-identity"
+ data-collector-deploy-copy-feedback-contract="angular-copy-success-duration-800"
+ data-collector-deploy-close-reset-contract="angular-close-clears-name"
+ data-collector-deploy-close-reset-owner="collector-route-controller"
+ data-collector-deploy-close-pending-result-contract="angular-close-ignores-late-generate"
+ data-collector-deploy-close-pending-result-owner="collector-route-controller"
+ data-collector-pagination-contract="angular-search-pagination"
+ data-collector-pagination-owner="hertzbeat-ui-pagination-bar"
+ >
+ <section data-collector-admin-layout="full-width-admin-list">
+ <span>刷新</span>
+ <span>部署采集器</span>
+ <span>上线采集器</span>
+ <span>下线采集器</span>
+ <span>删除采集器</span>
+ <span>搜索</span>
+ <span>采集集群</span>
+ <span>{search}</span>
+ <span>{selectedCollectors?.length ?? 0}</span>
+ <span>{actionMessage ?? 'no-action-message'}</span>
+ <span>{actionError ?? 'no-action-error'}</span>
+ <span>{actionMeta ?? 'no-action-meta'}</span>
+ <span>{actionTone ?? 'no-action-tone'}</span>
+ <span>{actionFeedbackContract ?? 'no-action-feedback-contract'}</span>
+ <span>{deleteTarget ? deleteTarget.label : 'no-delete-target'}</span>
+ <span>{isDeletePending ? 'delete-pending' : 'delete-idle'}</span>
+ <span>{isLoadPending ? 'route-load-pending' : 'route-load-idle'}</span>
+ <span data-test-deploy-open={deployOpen ? 'true' : 'false'}>{deployOpen ? 'deploy-open' : 'deploy-closed'}</span>
+ <span data-test-deploy-name={deployName ?? ''}>{deployName ?? ''}</span>
+ <span data-test-deploy-identity={deployIdentity ?? ''}>{deployIdentity ?? ''}</span>
+ <span data-test-deploy-error={deployError ?? ''}>{deployError ?? ''}</span>
+ <span data-test-deploy-copy={deployCopyMessage ?? ''}>{deployCopyMessage ?? ''}</span>
+ <span data-test-deploy-pending={isDeployPending ? 'true' : 'false'}>{isDeployPending ? 'deploy-pending' : 'deploy-idle'}</span>
+ <span>{deployNameValidationVisible ? 'deploy-name-dirty-invalid' : 'deploy-name-pristine'}</span>
+ <span>{data.list.content[0]?.collector.name ?? 'no-collector-name'}</span>
+ <span>{data.list.content[0]?.collector.ip ?? 'no-collector-ip'}</span>
+ <span>collector-pagination</span>
+ <button data-test-deploy-open-button="true" onClick={onDeploy}>deploy</button>
+ <button data-test-deploy-name-button="true" onClick={() => onDeployNameChange?.(' edge-b ')}>name</button>
+ <button data-test-deploy-generate-button="true" onClick={onDeployGenerate}>generate</button>
+ <button data-test-deploy-close-button="true" onClick={onDeployClose}>close</button>
+ </section>
+ </div>
+ );
+ }
}));
vi.mock('@/lib/api-client', () => ({
- apiMessageGet
+ apiMessageGet,
+ apiMessageDelete,
+ apiMessagePost,
+ apiMessagePut
}));
vi.mock('@/lib/format', () => ({
@@ -93,17 +206,45 @@ vi.mock('@/lib/format', () => ({
}));
vi.mock('@/lib/collector-manage/controller', () => ({
- loadCollectorData
+ loadCollectorData,
+ buildCollectorDeployCommands,
+ deleteCollectors,
+ generateCollectorIdentity,
+ goOnlineCollectors,
+ goOfflineCollectors
}));
describe('setting collector page', () => {
+ let interactionContainer: HTMLDivElement | null = null;
+ let interactionRoot: Root | null = null;
+
beforeEach(() => {
mockState.lastLoad = null;
+ mockState.lastSurfaceProps = null;
+ mockState.renderErrorMessage = null;
apiMessageGet.mockReset();
+ apiMessageDelete.mockReset();
+ apiMessagePost.mockReset();
+ apiMessagePut.mockReset();
loadCollectorData.mockReset().mockImplementation(async (apiGet, query) => {
- await apiGet(`/collector?pageIndex=0&pageSize=8${query.search ? `&name=${query.search}` : ''}`);
+ await apiGet(`/collector?pageIndex=${query.pageIndex}&pageSize=${query.pageSize}${query.search ? `&name=${query.search}` : ''}`);
return mockState.renderData;
});
+ buildCollectorDeployCommands.mockReset().mockReturnValue({ dockerShell: 'docker shell', packageShell: 'package shell' });
+ deleteCollectors.mockReset().mockResolvedValue(undefined);
+ generateCollectorIdentity.mockReset().mockResolvedValue({ identity: 'identity-123', host: '10.0.0.10' });
+ goOnlineCollectors.mockReset().mockResolvedValue(undefined);
+ goOfflineCollectors.mockReset().mockResolvedValue(undefined);
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ interactionRoot?.unmount();
+ await Promise.resolve();
+ });
+ interactionRoot = null;
+ interactionContainer?.remove();
+ interactionContainer = null;
});
it('renders the collector toolbar and table shell inside the route contract', async () => {
@@ -113,6 +254,53 @@ describe('setting collector page', () => {
expect(html).toContain('data-client-workbench="true"');
expect(html).toContain('data-collector-manage-surface="otlp-cold-collector-console"');
expect(html).toContain('data-collector-manage-style-baseline="hertzbeat-cold-matte"');
+ expect(html).toContain('data-collector-delete-warning-contract="angular-no-select-warning"');
+ expect(html).toContain('data-collector-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-collector-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-collector-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-delete-api-contract="angular-repeated-collectors-query"');
+ expect(html).toContain('data-collector-delete-page-clamp-contract="angular-update-page-index"');
+ expect(html).toContain('data-collector-delete-page-clamp-owner="route-state-contract"');
+ expect(html).toContain('data-collector-search-submit-contract="angular-enter-and-clear"');
+ expect(html).toContain('data-collector-search-clear-contract="angular-cleared-load"');
+ expect(html).toContain('data-collector-search-clear-owner="shared-search-row"');
+ expect(html).toContain('data-collector-operate-warning-contract="angular-no-select-online-offline"');
+ expect(html).toContain('data-collector-operate-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-collector-operate-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-operate-feedback-contract="angular-operate-notify"');
+ expect(html).toContain('data-collector-operate-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-operate-api-contract="angular-put-repeated-collectors-query"');
+ expect(html).toContain('data-collector-mutation-loading-contract="angular-nz-table-loading"');
+ expect(html).toContain('data-collector-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-collector-load-failure-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-load-failure="none"');
+ expect(html).toContain('data-collector-table-loading-contract="angular-load-collectors-table"');
+ expect(html).toContain('data-collector-table-loading-owner="angular-nz-table-loading"');
+ expect(html).toContain('data-collector-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(html).toContain('data-collector-table-loading-source="idle"');
+ expect(html).toContain('data-collector-deploy-modal-contract="angular-generate-identity-modal"');
+ expect(html).toContain('data-collector-deploy-api-contract="angular-post-generate-identity"');
+ expect(html).toContain('data-collector-deploy-failure-contract="angular-apply-fail-notification"');
+ expect(html).toContain('data-collector-deploy-validation-contract="angular-submit-marks-required"');
+ expect(html).toContain('data-collector-deploy-validation-raw-contract="angular-required-before-trim"');
+ expect(html).toContain('data-collector-deploy-validation-raw-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-deploy-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-collector-deploy-mask-contract="angular-mask-closable-false"');
+ expect(html).toContain('data-collector-deploy-width-contract="angular-width-45-percent"');
+ expect(html).toContain('data-collector-deploy-field-layout-contract="angular-label-7-control-12"');
+ expect(html).toContain('data-collector-deploy-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-collector-deploy-command-contract="angular-docker-package-shells"');
+ expect(html).toContain('data-collector-deploy-command-owner="hertzbeat-ui-code-editor"');
+ expect(html).toContain('data-collector-deploy-package-link-contract="angular-github-releases-link"');
+ expect(html).toContain('data-collector-deploy-copy-contract="angular-copy-identity"');
+ expect(html).toContain('data-collector-deploy-copy-feedback-contract="angular-copy-success-duration-800"');
+ expect(html).toContain('data-collector-deploy-close-reset-contract="angular-close-clears-name"');
+ expect(html).toContain('data-collector-deploy-close-reset-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-deploy-close-pending-result-contract="angular-close-ignores-late-generate"');
+ expect(html).toContain('data-collector-deploy-close-pending-result-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-pagination-contract="angular-search-pagination"');
+ expect(html).toContain('data-collector-pagination-owner="hertzbeat-ui-pagination-bar"');
expect(html).toContain('data-collector-admin-layout="full-width-admin-list"');
expect(html).not.toContain('data-collector-summary-rail=');
expect(html).not.toContain('当前采集集群');
@@ -124,11 +312,190 @@ describe('setting collector page', () => {
expect(html).toContain('删除采集器');
expect(html).toContain('搜索');
expect(html).toContain('采集集群');
+ expect(html).toContain('no-action-message');
+ expect(html).toContain('no-action-error');
+ expect(html).toContain('no-action-meta');
+ expect(html).toContain('success');
+ expect(html).toContain('no-action-feedback-contract');
+ expect(html).toContain('no-delete-target');
+ expect(html).toContain('delete-idle');
+ expect(html).toContain('route-load-idle');
+ expect(html).toContain('deploy-name-pristine');
expect(html).toContain('edge-a');
expect(html).toContain('10.0.0.1');
+ expect(html).toContain('collector-pagination');
await mockState.lastLoad?.();
expect(apiMessageGet).toHaveBeenCalledWith('/collector?pageIndex=0&pageSize=8');
});
+
+ it('keeps the collector shell mounted on list load failure like Angular console-only handling', async () => {
+ mockState.renderErrorMessage = 'backend refused collector load';
+ const { default: SettingCollectorPage } = await import('./page');
+ const html = renderToStaticMarkup(<SettingCollectorPage />);
+
+ expect(html).toContain('data-client-workbench="true"');
+ expect(html).toContain('data-collector-manage-surface="otlp-cold-collector-console"');
+ expect(html).toContain('data-collector-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-collector-load-failure-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-load-failure="angular-console-only-shell"');
+ expect(html).toContain('data-collector-admin-layout="full-width-admin-list"');
+ expect(html).toContain('collector-pagination');
+ expect(html).not.toContain('backend refused collector load');
+ });
+
+ it('keeps collector management remounts on a short settled cache window while reloads invalidate it', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/collector/setting-collector-page.tsx'), 'utf8');
+
+ expect(source).toContain('SETTING_COLLECTOR_SETTLED_CACHE_TTL_MS = 10_000');
+ expect(source).toContain("['setting-collector', collectorListUrl, reloadVersion].join(':')");
+ expect(source).toContain('[collectorListUrl, reloadVersion]');
+ expect(source).toContain("const [query, setQuery] = useState<CollectorQueryState>({ pageIndex: 0, pageSize: 8, search: '' })");
+ expect(source).toContain('const [selectedCollectors, setSelectedCollectors] = useState<string[]>([])');
+ expect(source).toContain('const [actionMessage, setActionMessage] = useState<string | null>(null)');
+ expect(source).toContain('const [actionError, setActionError] = useState<string | null>(null)');
+ expect(source).toContain('const [actionMeta, setActionMeta] = useState<string | null>(null)');
+ expect(source).toContain("const [actionTone, setActionTone] = useState<'success' | 'warning' | 'critical'>('success')");
+ expect(source).toContain('const [actionFeedbackContract, setActionFeedbackContract] = useState<string | null>(null)');
+ expect(source).toContain('const [deleteTarget, setDeleteTarget] = useState<CollectorDeleteTarget | null>(null)');
+ expect(source).toContain('const [isDeletePending, setIsDeletePending] = useState(false)');
+ expect(source).toContain('const [isLoadPending, setIsLoadPending] = useState(false)');
+ expect(source).toContain('buildFallbackCollectorData');
+ expect(source).toContain('renderError={(message, retry) => renderCollectorSurface(buildFallbackCollectorData(), message, retry)}');
+ expect(source).toContain('loadError={loadError}');
+ expect(source).toContain('const [deployOpen, setDeployOpen] = useState(false)');
+ expect(source).toContain("const [deployHost, setDeployHost] = useState('127.0.0.1')");
+ expect(source).toContain('const [deployDockerShell, setDeployDockerShell] = useState');
+ expect(source).toContain('const [deployPackageShell, setDeployPackageShell] = useState');
+ expect(source).toContain('const [deployNameValidationVisible, setDeployNameValidationVisible] = useState(false)');
+ expect(source).toContain("setActionError(t('common.notify.no-select-delete'))");
+ expect(source).toContain("setActionError(t(action === 'online' ? 'collector.notify.no-select-online' : 'collector.notify.no-select-offline'))");
+ expect(source).toContain("setActionMessage(t(deleteTarget.action === 'online' || deleteTarget.action === 'offline' ? 'common.notify.operate-success' : 'common.notify.delete-success'))");
+ expect(source).toContain("setActionError(t(isOperateAction ? 'common.notify.operate-fail' : 'common.notify.delete-fail'))");
+ expect(source).toContain('setActionMeta(error instanceof Error ? error.message : null)');
+ expect(source).toContain('const remainingTotal = Math.max(0, data.list.totalElements - targetCollectors.length)');
+ expect(source).toContain('const lastPageIndex = Math.max(0, Math.ceil(remainingTotal / current.pageSize) - 1)');
+ expect(source).toContain('current.pageIndex > lastPageIndex ? { ...current, pageIndex: lastPageIndex } : current');
+ expect(source).toContain('setSelectedCollectors([]);');
+ expect(source).not.toContain('setSelectedCollectors(collectors => collectors.filter');
+ expect(source).toContain('deleteCollectors(apiMessageDelete, targetCollectors)');
+ expect(source).toContain('goOnlineCollectors(apiMessagePut, targetCollectors)');
+ expect(source).toContain('goOfflineCollectors(apiMessagePut, targetCollectors)');
+ expect(source).toContain('generateCollectorIdentity(apiMessagePost, collector)');
+ expect(source).toContain('buildCollectorDeployCommands(t, identity, managerHost)');
+ expect(source).toContain('COLLECTOR_DEPLOY_COPY_SUCCESS_DURATION_MS = 800');
+ expect(source).toContain('const deployCopyClearTimerRef = useRef');
+ expect(source).toContain('const deployGenerationRequestRef = useRef(0);');
+ expect(source).toContain('const clearDeployCopyTimer = useCallback');
+ expect(source).toContain('const closeDeployDialog = useCallback');
+ expect(source).toContain('deployGenerationRequestRef.current += 1;');
+ expect(source).toContain("setDeployOpen(false);");
+ expect(source).toContain("setDeployName('');");
+ expect(source).toContain('window.clearTimeout(deployCopyClearTimerRef.current)');
+ expect(source).toContain('deployCopyClearTimerRef.current = window.setTimeout');
+ expect(source).toContain('COLLECTOR_DEPLOY_COPY_SUCCESS_DURATION_MS');
+ expect(source).toContain('setIsDeployPending(true);');
+ expect(source).toContain('setIsDeployPending(false);');
+ expect(source).toContain("setDeployError(error instanceof Error ? error.message : t('common.notify.apply-fail'))");
+ expect(source).toContain('setIsLoadPending(true);');
+ expect(source).toContain('setIsLoadPending(false);');
+ expect(source).toContain('return await loadCollectorData(apiMessageGet, query);');
+ expect(source).toContain("setDeployError(error instanceof Error ? error.message : t('common.notify.apply-fail'))");
+ expect(source).toContain("setDeployCopyMessage(t('common.notify.copy-success'))");
+ expect(source).toContain('onRefresh={() => {');
+ expect(source).toContain('setReloadVersion(version => version + 1);');
+ expect(source).toContain('selectedCollectors={selectedCollectors}');
+ expect(source).toContain('actionMeta={actionMeta}');
+ expect(source).toContain('actionFeedbackContract={actionFeedbackContract}');
+ expect(source).toContain('deployOpen={deployOpen}');
+ expect(source).toContain('deployIdentity={deployIdentity}');
+ expect(source).toContain('deployDockerShell={deployDockerShell}');
+ expect(source).toContain('deployPackageShell={deployPackageShell}');
+ expect(source).toContain('deployNameValidationVisible={deployNameValidationVisible}');
+ expect(source).toContain('onSelectedCollectorsChange={setSelectedCollectors}');
+ expect(source).toContain('onPageIndexChange={pageIndex => {');
+ expect(source).toContain('setQuery(current => ({ ...current, pageIndex }))');
+ expect(source).toContain('onPageSizeChange={pageSize => {');
+ expect(source).toContain('setQuery(current => ({ ...current, pageIndex: 0, pageSize }))');
+ expect(source).toContain('setQuery(current => ({ ...current, pageIndex: 0, search }))');
+ expect(source).toContain('onSearchClear={() => {');
+ expect(source).toContain("setSearch('');");
+ expect(source).toContain("setQuery(current => ({ ...current, pageIndex: 0, search: '' }))");
+ expect(source).toContain('onDeployGenerate={() => {');
+ expect(source).toContain('if (isDeployPending) return;');
+ expect(source).toContain('const collector = deployName.trim();');
+ expect(source).toContain('if (deployName.length === 0) {');
+ expect(source).toContain('const deployGenerationRequest = deployGenerationRequestRef.current + 1;');
+ expect(source).toContain('deployGenerationRequestRef.current = deployGenerationRequest;');
+ expect(source).toContain('if (deployGenerationRequestRef.current !== deployGenerationRequest) return;');
+ expect(source).toContain('setDeployNameValidationVisible(true);');
+ expect(source).toContain('setDeployNameValidationVisible(false);');
+ expect(source).toContain('onDeployCopy={value => {');
+ expect(source).toContain("onGoOnline={() => openBatchOperateTarget('online')}");
+ expect(source).toContain("onGoOffline={() => openBatchOperateTarget('offline')}");
+ expect(source).toContain('deleteTarget={deleteTarget}');
+ expect(source).toContain('isDeletePending={isDeletePending}');
+ expect(source).toContain('isLoadPending={isLoadPending}');
+ expect(source).toContain('cacheKey={collectorManageCacheKey}');
+ expect(source).toContain('cacheSettledTtlMs={SETTING_COLLECTOR_SETTLED_CACHE_TTL_MS}');
+ });
+
+ it('ignores late collector deploy identity results after the dialog is closed', async () => {
+ let resolveGenerate: ((value: { identity: string; host: string }) => void) | undefined;
+ generateCollectorIdentity.mockImplementationOnce(() => new Promise(resolve => {
+ resolveGenerate = resolve as (value: { identity: string; host: string }) => void;
+ }));
+ const { default: SettingCollectorPage } = await import('./page');
+ interactionContainer = document.createElement('div');
+ document.body.appendChild(interactionContainer);
+ interactionRoot = createRoot(interactionContainer);
+
+ await act(async () => {
+ interactionRoot?.render(<SettingCollectorPage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeploy();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeployNameChange(' edge-b ');
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeployGenerate();
+ await Promise.resolve();
+ });
+
+ expect(generateCollectorIdentity).toHaveBeenCalledWith(apiMessagePost, 'edge-b');
+ expect(mockState.lastSurfaceProps?.isDeployPending).toBe(true);
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeployClose();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.deployOpen).toBe(false);
+ expect(mockState.lastSurfaceProps?.deployName).toBe('');
+ expect(mockState.lastSurfaceProps?.deployIdentity).toBe('');
+ expect(mockState.lastSurfaceProps?.isDeployPending).toBe(false);
+
+ await act(async () => {
+ resolveGenerate?.({ identity: 'late-identity', host: '10.0.0.20' });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.deployOpen).toBe(false);
+ expect(mockState.lastSurfaceProps?.deployName).toBe('');
+ expect(mockState.lastSurfaceProps?.deployIdentity).toBe('');
+ expect(mockState.lastSurfaceProps?.deployDockerShell).toBe('');
+ expect(mockState.lastSurfaceProps?.deployPackageShell).toBe('');
+ expect(mockState.lastSurfaceProps?.isDeployPending).toBe(false);
+ expect(buildCollectorDeployCommands).not.toHaveBeenCalled();
+ });
});
diff --git a/web-next/app/setting/collector/page.tsx b/web-next/app/setting/collector/page.tsx
index f2f93c3abe..fa779d1efd 100644
--- a/web-next/app/setting/collector/page.tsx
+++ b/web-next/app/setting/collector/page.tsx
@@ -1,43 +1,7 @@
-'use client';
+import React from 'react';
-import { useCallback, useState } from 'react';
-import { ClientWorkbench } from '@/components/workbench/client-workbench';
-import { useI18n } from '@/components/providers/i18n-provider';
-import { CollectorManageSurface } from '@/components/pages/collector-manage-surface';
-import { apiMessageGet } from '@/lib/api-client';
-import { formatTime } from '@/lib/format';
-import { loadCollectorData } from '@/lib/collector-manage/controller';
-import type { CollectorQueryState } from '@/lib/collector-manage/query-state';
+import SettingCollectorPage from './setting-collector-page';
-export default function SettingCollectorPage() {
- const { t } = useI18n();
- const [search, setSearch] = useState('');
- const [query, setQuery] = useState<CollectorQueryState>({ search: '' });
-
- const load = useCallback(async () => {
- return loadCollectorData(apiMessageGet, query);
- }, [query]);
-
- return (
- <ClientWorkbench load={load} loadingCopy={t('menu.advanced.collector')}>
- {data => (
- <CollectorManageSurface
- t={t}
- data={data}
- search={search}
- formatTime={formatTime}
- onSearchChange={setSearch}
- onSearch={() => setQuery({ search })}
- onRefresh={() => setQuery(current => ({ ...current }))}
- onDeploy={() => {}}
- onGoOnline={() => {}}
- onGoOffline={() => {}}
- onDelete={() => {}}
- onRowGoOnline={() => {}}
- onRowGoOffline={() => {}}
- onRowDelete={() => {}}
- />
- )}
- </ClientWorkbench>
- );
+export default function SettingCollectorRoutePage() {
+ return <SettingCollectorPage />;
}
diff --git a/web-next/app/setting/collector/setting-collector-page.tsx b/web-next/app/setting/collector/setting-collector-page.tsx
new file mode 100644
index 0000000000..14dd340616
--- /dev/null
+++ b/web-next/app/setting/collector/setting-collector-page.tsx
@@ -0,0 +1,340 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ClientWorkbench } from '@/components/workbench/client-workbench';
+import { useI18n } from '@/components/providers/i18n-provider';
+import { CollectorManageSurface, type CollectorDeleteTarget } from '@/components/pages/collector-manage-surface';
+import { apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
+import { formatTime } from '@/lib/format';
+import {
+ buildCollectorDeployCommands,
+ deleteCollectors,
+ generateCollectorIdentity,
+ goOfflineCollectors,
+ goOnlineCollectors,
+ loadCollectorData
+} from '@/lib/collector-manage/controller';
+import { buildCollectorUrl, type CollectorQueryState } from '@/lib/collector-manage/query-state';
+
+const SETTING_COLLECTOR_SETTLED_CACHE_TTL_MS = 10_000;
+const COLLECTOR_DEPLOY_COPY_SUCCESS_DURATION_MS = 800;
+type CollectorManagePageData = Awaited<ReturnType<typeof loadCollectorData>>;
+
+export default function SettingCollectorPage() {
+ const { t } = useI18n();
+ const [search, setSearch] = useState('');
+ const [query, setQuery] = useState<CollectorQueryState>({ pageIndex: 0, pageSize: 8, search: '' });
+ const [reloadVersion, setReloadVersion] = useState(0);
+ const [selectedCollectors, setSelectedCollectors] = useState<string[]>([]);
+ const [actionMessage, setActionMessage] = useState<string | null>(null);
+ const [actionError, setActionError] = useState<string | null>(null);
+ const [actionMeta, setActionMeta] = useState<string | null>(null);
+ const [actionTone, setActionTone] = useState<'success' | 'warning' | 'critical'>('success');
+ const [actionFeedbackContract, setActionFeedbackContract] = useState<string | null>(null);
+ const [deleteTarget, setDeleteTarget] = useState<CollectorDeleteTarget | null>(null);
+ const [isDeletePending, setIsDeletePending] = useState(false);
+ const [isLoadPending, setIsLoadPending] = useState(false);
+ const [deployOpen, setDeployOpen] = useState(false);
+ const [deployName, setDeployName] = useState('');
+ const [deployIdentity, setDeployIdentity] = useState('');
+ const [deployHost, setDeployHost] = useState('127.0.0.1');
+ const [deployDockerShell, setDeployDockerShell] = useState('');
+ const [deployPackageShell, setDeployPackageShell] = useState('');
+ const [deployError, setDeployError] = useState<string | null>(null);
+ const [deployCopyMessage, setDeployCopyMessage] = useState<string | null>(null);
+ const [isDeployPending, setIsDeployPending] = useState(false);
+ const [deployNameValidationVisible, setDeployNameValidationVisible] = useState(false);
+ const deployCopyClearTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
+ const deployGenerationRequestRef = useRef(0);
+ const collectorListUrl = useMemo(() => buildCollectorUrl(query), [query]);
+ const collectorManageCacheKey = useMemo(
+ () => ['setting-collector', collectorListUrl, reloadVersion].join(':'),
+ [collectorListUrl, reloadVersion]
+ );
+
+ const load = useCallback(async () => {
+ setIsLoadPending(true);
+ try {
+ return await loadCollectorData(apiMessageGet, query);
+ } finally {
+ setIsLoadPending(false);
+ }
+ }, [query]);
+
+ const clearActionFeedback = useCallback(() => {
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setActionTone('success');
+ setActionFeedbackContract(null);
+ }, []);
+
+ const buildFallbackCollectorData = useCallback((): CollectorManagePageData => ({
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: query.pageIndex ?? 0,
+ pageSize: query.pageSize ?? 8
+ }
+ }), [query.pageIndex, query.pageSize]);
+
+ const clearDeployCopyTimer = useCallback(() => {
+ if (!deployCopyClearTimerRef.current) return;
+ window.clearTimeout(deployCopyClearTimerRef.current);
+ deployCopyClearTimerRef.current = null;
+ }, []);
+
+ useEffect(() => clearDeployCopyTimer, [clearDeployCopyTimer]);
+
+ const openBatchOperateTarget = useCallback((action: 'online' | 'offline') => {
+ clearActionFeedback();
+ if (selectedCollectors.length === 0) {
+ setActionTone('warning');
+ setActionFeedbackContract(action === 'online' ? 'angular-no-select-online' : 'angular-no-select-offline');
+ setActionError(t(action === 'online' ? 'collector.notify.no-select-online' : 'collector.notify.no-select-offline'));
+ return;
+ }
+ setDeleteTarget({
+ action,
+ collectors: selectedCollectors,
+ label: t('setting.collector.selected-count', { count: selectedCollectors.length }),
+ mode: 'batch'
+ });
+ }, [clearActionFeedback, selectedCollectors, t]);
+
+ const closeDeployDialog = useCallback(() => {
+ clearDeployCopyTimer();
+ deployGenerationRequestRef.current += 1;
+ setDeployOpen(false);
+ setDeployName('');
+ setDeployIdentity('');
+ setDeployHost('127.0.0.1');
+ setDeployDockerShell('');
+ setDeployPackageShell('');
+ setDeployError(null);
+ setDeployCopyMessage(null);
+ setIsDeployPending(false);
+ setDeployNameValidationVisible(false);
+ }, [clearDeployCopyTimer]);
+
+ function renderCollectorSurface(data: CollectorManagePageData, loadError: string | null = null, retryLoad?: () => void) {
+ return (
+ <CollectorManageSurface
+ t={t}
+ data={data}
+ search={search}
+ selectedCollectors={selectedCollectors}
+ actionMessage={actionMessage}
+ actionError={actionError}
+ actionMeta={actionMeta}
+ actionTone={actionTone}
+ actionFeedbackContract={actionFeedbackContract}
+ loadError={loadError}
+ deleteTarget={deleteTarget}
+ isDeletePending={isDeletePending}
+ isLoadPending={isLoadPending}
+ deployOpen={deployOpen}
+ deployName={deployName}
+ deployIdentity={deployIdentity}
+ deployHost={deployHost}
+ deployDockerShell={deployDockerShell}
+ deployPackageShell={deployPackageShell}
+ deployError={deployError}
+ deployCopyMessage={deployCopyMessage}
+ isDeployPending={isDeployPending}
+ deployNameValidationVisible={deployNameValidationVisible}
+ formatTime={formatTime}
+ onSearchChange={setSearch}
+ onSearch={() => {
+ clearActionFeedback();
+ setSelectedCollectors([]);
+ setQuery(current => ({ ...current, pageIndex: 0, search }));
+ }}
+ onSearchClear={() => {
+ clearActionFeedback();
+ setSelectedCollectors([]);
+ setSearch('');
+ setQuery(current => ({ ...current, pageIndex: 0, search: '' }));
+ }}
+ onRefresh={() => {
+ clearActionFeedback();
+ setSelectedCollectors([]);
+ if (retryLoad) {
+ retryLoad();
+ return;
+ }
+ setReloadVersion(version => version + 1);
+ }}
+ onDeploy={() => {
+ clearDeployCopyTimer();
+ clearActionFeedback();
+ setDeployOpen(true);
+ setDeployIdentity('');
+ setDeployError(null);
+ setDeployCopyMessage(null);
+ setDeployNameValidationVisible(false);
+ }}
+ onGoOnline={() => openBatchOperateTarget('online')}
+ onGoOffline={() => openBatchOperateTarget('offline')}
+ onDelete={() => {
+ clearActionFeedback();
+ if (selectedCollectors.length === 0) {
+ setActionTone('warning');
+ setActionFeedbackContract('angular-no-select-warning');
+ setActionError(t('common.notify.no-select-delete'));
+ return;
+ }
+ setDeleteTarget({
+ action: 'delete',
+ collectors: selectedCollectors,
+ label: t('setting.collector.selected-count', { count: selectedCollectors.length }),
+ mode: 'batch'
+ });
+ }}
+ onSelectedCollectorsChange={setSelectedCollectors}
+ onPageIndexChange={pageIndex => {
+ clearActionFeedback();
+ setSelectedCollectors([]);
+ setQuery(current => ({ ...current, pageIndex }));
+ }}
+ onPageSizeChange={pageSize => {
+ clearActionFeedback();
+ setSelectedCollectors([]);
+ setQuery(current => ({ ...current, pageIndex: 0, pageSize }));
+ }}
+ onDeleteCancel={() => {
+ if (!isDeletePending) setDeleteTarget(null);
+ }}
+ onDeleteConfirm={() => {
+ if (!deleteTarget) return;
+ clearActionFeedback();
+ setIsDeletePending(true);
+ const targetCollectors = deleteTarget.collectors;
+ const mutation =
+ deleteTarget.action === 'online'
+ ? goOnlineCollectors(apiMessagePut, targetCollectors)
+ : deleteTarget.action === 'offline'
+ ? goOfflineCollectors(apiMessagePut, targetCollectors)
+ : deleteCollectors(apiMessageDelete, targetCollectors);
+ void mutation
+ .then(() => {
+ setActionTone('success');
+ setActionFeedbackContract(deleteTarget.action === 'online' || deleteTarget.action === 'offline' ? 'angular-operate-notify' : 'angular-delete-notify');
+ setActionMessage(t(deleteTarget.action === 'online' || deleteTarget.action === 'offline' ? 'common.notify.operate-success' : 'common.notify.delete-success'));
+ setSelectedCollectors([]);
+ if (deleteTarget.action !== 'online' && deleteTarget.action !== 'offline') {
+ setQuery(current => {
+ const remainingTotal = Math.max(0, data.list.totalElements - targetCollectors.length);
+ const lastPageIndex = Math.max(0, Math.ceil(remainingTotal / current.pageSize) - 1);
+ return current.pageIndex > lastPageIndex ? { ...current, pageIndex: lastPageIndex } : current;
+ });
+ }
+ setDeleteTarget(null);
+ setReloadVersion(version => version + 1);
+ })
+ .catch(error => {
+ const isOperateAction = deleteTarget.action === 'online' || deleteTarget.action === 'offline';
+ setActionTone('critical');
+ setActionFeedbackContract(isOperateAction ? 'angular-operate-fail' : 'angular-delete-fail');
+ setActionError(t(isOperateAction ? 'common.notify.operate-fail' : 'common.notify.delete-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsDeletePending(false);
+ });
+ }}
+ onDeployNameChange={value => {
+ clearDeployCopyTimer();
+ setDeployName(value);
+ setDeployError(null);
+ setDeployCopyMessage(null);
+ setDeployNameValidationVisible(false);
+ }}
+ onDeployGenerate={() => {
+ const collector = deployName.trim();
+ if (isDeployPending) return;
+ if (deployName.length === 0) {
+ setDeployNameValidationVisible(true);
+ return;
+ }
+ setDeployError(null);
+ clearDeployCopyTimer();
+ setDeployCopyMessage(null);
+ setDeployNameValidationVisible(false);
+ setIsDeployPending(true);
+ const deployGenerationRequest = deployGenerationRequestRef.current + 1;
+ deployGenerationRequestRef.current = deployGenerationRequest;
+ void generateCollectorIdentity(apiMessagePost, collector)
+ .then(result => {
+ if (deployGenerationRequestRef.current !== deployGenerationRequest) return;
+ const identity = result.identity ?? '';
+ const managerHost = result.host ?? '127.0.0.1';
+ const commands = buildCollectorDeployCommands(t, identity, managerHost);
+ setDeployName(collector);
+ setDeployIdentity(identity);
+ setDeployHost(managerHost);
+ setDeployDockerShell(commands.dockerShell);
+ setDeployPackageShell(commands.packageShell);
+ })
+ .catch(error => {
+ if (deployGenerationRequestRef.current !== deployGenerationRequest) return;
+ setDeployError(error instanceof Error ? error.message : t('common.notify.apply-fail'));
+ })
+ .finally(() => {
+ if (deployGenerationRequestRef.current !== deployGenerationRequest) return;
+ setIsDeployPending(false);
+ });
+ }}
+ onDeployClose={closeDeployDialog}
+ onDeployCopy={value => {
+ clearDeployCopyTimer();
+ setDeployCopyMessage(t('common.notify.copy-success'));
+ deployCopyClearTimerRef.current = window.setTimeout(() => {
+ setDeployCopyMessage(null);
+ deployCopyClearTimerRef.current = null;
+ }, COLLECTOR_DEPLOY_COPY_SUCCESS_DURATION_MS);
+ void navigator.clipboard?.writeText(value).catch(() => undefined);
+ }}
+ onRowGoOnline={collector => {
+ clearActionFeedback();
+ setDeleteTarget({
+ action: 'online',
+ collectors: [collector],
+ label: collector,
+ mode: 'single'
+ });
+ }}
+ onRowGoOffline={collector => {
+ clearActionFeedback();
+ setDeleteTarget({
+ action: 'offline',
+ collectors: [collector],
+ label: collector,
+ mode: 'single'
+ });
+ }}
+ onRowDelete={collector => {
+ clearActionFeedback();
+ setDeleteTarget({
+ action: 'delete',
+ collectors: [collector],
+ label: collector,
+ mode: 'single'
+ });
+ }}
+ />
+ );
+ }
+
+ return (
+ <ClientWorkbench
+ load={load}
+ loadingCopy={t('setting.collector.loading')}
+ cacheKey={collectorManageCacheKey}
+ cacheSettledTtlMs={SETTING_COLLECTOR_SETTLED_CACHE_TTL_MS}
+ renderError={(message, retry) => renderCollectorSurface(buildFallbackCollectorData(), message, retry)}
+ >
+ {data => renderCollectorSurface(data)}
+ </ClientWorkbench>
+ );
+}
diff --git a/web-next/app/setting/define/page.test.tsx b/web-next/app/setting/define/page.test.tsx
index 5d74653518..357cf19468 100644
--- a/web-next/app/setting/define/page.test.tsx
+++ b/web-next/app/setting/define/page.test.tsx
@@ -1,52 +1,64 @@
+// @vitest-environment jsdom
+
import React from 'react';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createTranslatorMock } from '../../../test/i18n-test-helper';
(globalThis as { React?: typeof React }).React = React;
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockState = vi.hoisted(() => ({
lastLoad: null as null | (() => Promise<unknown>),
+ lastSurfaceProps: null as null | Record<string, any>,
+ renderErrorMessage: null as string | null,
renderData: {
- list: {
- content: [
- {
- id: 1,
- name: 'cpu-alert',
- type: 'realtime_metric',
- datasource: 'http',
- expr: 'up == 0',
- enable: true,
- period: 60
- }
- ],
- totalElements: 1,
- pageIndex: 0,
- pageSize: 8
- },
- datasourceStatus: {
- code: 0,
- data: {
- http: 'ready'
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
}
- }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database',
+ originalYaml: 'app: mysql\ncategory: database'
+ },
+ defaultRenderData: {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database',
+ originalYaml: 'app: mysql\ncategory: database'
}
}));
-const apiGet = vi.hoisted(() => vi.fn());
const apiMessageGet = vi.hoisted(() => vi.fn());
const apiMessagePost = vi.hoisted(() => vi.fn());
const apiMessagePut = vi.hoisted(() => vi.fn());
+const apiMessageDelete = vi.hoisted(() => vi.fn());
const loadDefineCenterData = vi.hoisted(() => vi.fn());
+const saveTemplateDefine = vi.hoisted(() => vi.fn());
+const deleteTemplateDefine = vi.hoisted(() => vi.fn());
+const updateTemplateVisibility = vi.hoisted(() => vi.fn());
+const reloadTemplateDefinitionStartupContext = vi.hoisted(() => vi.fn());
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
+ locale: 'zh-CN',
t: createTranslatorMock({
- locale: 'zh-CN',
- overrides: {
- 'setting.define.loading': '加载定义中心',
- 'menu.advanced.define': '定义'
- }
+ locale: 'zh-CN'
})
})
}));
@@ -54,80 +66,848 @@ vi.mock('@/components/providers/i18n-provider', () => ({
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
- load
+ load,
+ renderError
}: {
children: (data: unknown) => React.ReactNode;
load: () => Promise<unknown>;
+ renderError?: (message: string, retry: () => void) => React.ReactNode;
}) => {
mockState.lastLoad = load;
+ if (mockState.renderErrorMessage && renderError) {
+ return <div data-client-workbench="true">{renderError(mockState.renderErrorMessage, vi.fn())}</div>;
+ }
return <div data-client-workbench="true">{children(mockState.renderData)}</div>;
}
}));
vi.mock('@/components/pages/setting-define-surface', () => ({
- SettingDefineSurface: ({ selectedDefine, yamlLabel, darkMode, isEditing }: any) => (
- <div data-setting-define-surface="otlp-cold-define-console">
- <span>定义</span>
- <span>{selectedDefine?.name}</span>
- <span>{yamlLabel}</span>
- <span>{String(darkMode)}</span>
- <span>{String(isEditing)}</span>
- </div>
- )
+ SettingDefineSurface: (props: any) => {
+ mockState.lastSurfaceProps = props;
+ const {
+ selectedApp,
+ yamlLabel,
+ editorValue,
+ originalYaml,
+ darkMode,
+ isEditing,
+ menuLoading,
+ editorLoading,
+ loadError,
+ message,
+ messageMeta,
+ messageContract,
+ onNew,
+ onSave,
+ onDelete,
+ onToggleTemplateVisibility
+ } = props;
+ return (
+ <div
+ data-setting-define-surface="otlp-cold-define-console"
+ data-setting-define-menu-filtered-contract="angular-hide-prometheus-system"
+ data-setting-define-menu-filtered-owner="setting-define-controller"
+ data-setting-define-menu-loading-contract="angular-monitor-select-list-loading"
+ data-setting-define-menu-loading-owner="hertzbeat-ui-template-picker"
+ data-setting-define-menu-loading={menuLoading ? 'true' : 'false'}
+ data-setting-define-load-failure-contract="angular-console-only-shell"
+ data-setting-define-load-failure-owner="setting-define-controller"
+ data-setting-define-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-setting-define-theme-switch-contract="angular-nz-switch-code-editor-theme"
+ data-setting-define-theme-switch-owner="hertzbeat-ui-switch"
+ data-setting-define-theme-switch-state={darkMode ? 'vs-dark' : 'vs'}
+ data-setting-define-save-reload-contract="angular-load-app-define-content-after-save"
+ data-setting-define-save-reload-owner="setting-define-controller"
+ data-setting-define-save-reload-scope={selectedApp ? 'existing-template' : 'new-template-draft'}
+ data-setting-define-startup-reload-contract="angular-startup-load-after-success"
+ data-setting-define-startup-reload-owner="startup-service"
+ data-setting-define-startup-reload-scope="save-delete-visibility-success"
+ data-setting-define-startup-reload-failure-contract="angular-fire-and-forget"
+ data-setting-define-startup-reload-failure-owner="startup-service"
+ data-setting-define-editor-option-contract="angular-yaml-vs-folding-automatic-layout"
+ data-setting-define-editor-option-owner="cold-code-editor"
+ data-setting-define-editor-loading-contract="angular-nz-code-editor-loading"
+ data-setting-define-editor-loading-owner="cold-code-editor"
+ data-setting-define-editor-loading={editorLoading ? 'true' : 'false'}
+ data-setting-define-editor-loading-save-contract="angular-save-hidden-while-editor-loading"
+ data-setting-define-new-draft-contract="angular-locale-comment-five-newlines"
+ data-setting-define-new-draft-owner="setting-define-controller"
+ data-setting-define-new-draft-state={selectedApp ? 'existing-template' : 'new-template'}
+ data-setting-define-new-action-contract="angular-current-app-reset-url-retained"
+ data-setting-define-new-action-owner="setting-define-controller"
+ data-setting-define-new-action-state={selectedApp ? 'available' : 'hidden'}
+ data-setting-define-menu-select-query-contract="angular-replace-with-app-only"
+ data-setting-define-menu-select-query-owner="setting-define-page-router"
+ >
+ <span>定义</span>
+ <span>{selectedApp}</span>
+ <span>{yamlLabel}</span>
+ <span>{editorValue}</span>
+ <span data-setting-define-original-yaml="true">{originalYaml}</span>
+ <span>{String(darkMode)}</span>
+ <span>{String(isEditing)}</span>
+ <span data-setting-define-load-error={loadError ? 'present' : 'none'} />
+ <span data-setting-define-test-save-pending="true">{String(props.savePending)}</span>
+ <span
+ data-setting-define-test-message={message || ''}
+ data-setting-define-test-message-meta={messageMeta || ''}
+ data-setting-define-test-message-contract={messageContract || ''}
+ />
+ <button data-setting-define-test-new="true" onClick={onNew}>new</button>
+ <button data-setting-define-test-save="true" onClick={onSave}>save</button>
+ <button data-setting-define-test-delete="true" onClick={onDelete}>delete</button>
+ <button data-setting-define-test-visibility="true" onClick={() => onToggleTemplateVisibility('mysql', true)}>visibility</button>
+ </div>
+ );
+ }
}));
vi.mock('@/lib/api-client', () => ({
- apiGet,
apiMessageGet,
apiMessagePost,
- apiMessagePut
-}));
-
-vi.mock('@/lib/format', () => ({
- formatTime: () => '2026-04-10 18:00:00'
+ apiMessagePut,
+ apiMessageDelete
}));
vi.mock('@/lib/setting-define/controller', () => ({
loadDefineCenterData,
- buildPreviewUrl: () => '/alert/define/preview/http?type=realtime_metric&expr=up+%3D%3D+0',
- buildSkeletonDefine: () => ({
- id: 0,
- name: 'next-migrated-define',
- datasource: 'promql',
- expr: 'up == 0'
- }),
- createSkeletonDefine: vi.fn(),
- saveDefine: vi.fn()
+ buildNewTemplateYaml: () => 'app: custom',
+ buildNewTemplateDraft: () => ({ yaml: 'app: custom\n\n\n\n\n', originalYaml: 'app: custom' }),
+ saveTemplateDefine,
+ deleteTemplateDefine,
+ updateTemplateVisibility,
+ reloadTemplateDefinitionStartupContext
}));
describe('setting define page', () => {
beforeEach(() => {
+ window.history.pushState({}, '', '/setting/define');
+ window.localStorage.clear();
+ document.body.removeAttribute('data-theme');
+ document.documentElement.removeAttribute('data-theme');
mockState.lastLoad = null;
- apiGet.mockReset();
+ mockState.lastSurfaceProps = null;
+ mockState.renderErrorMessage = null;
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
apiMessageGet.mockReset();
apiMessagePost.mockReset();
apiMessagePut.mockReset();
- loadDefineCenterData.mockReset().mockImplementation(async (apiGetFn, search) => {
- await apiGetFn(`/alert/defines?pageIndex=0&pageSize=8&sort=id&order=desc${search ? `&search=${search}` : ''}`);
- await apiGetFn('/alert/define/datasource/status');
- return mockState.renderData;
- });
+ apiMessageDelete.mockReset();
+ loadDefineCenterData.mockReset().mockResolvedValue(mockState.renderData);
+ saveTemplateDefine.mockReset().mockResolvedValue(undefined);
+ deleteTemplateDefine.mockReset().mockResolvedValue(undefined);
+ updateTemplateVisibility.mockReset().mockResolvedValue(undefined);
+ reloadTemplateDefinitionStartupContext.mockReset().mockResolvedValue(undefined);
});
- it('renders the shared define surface and keeps the route focused on load composition', async () => {
+ it('renders the monitor-template YML surface and loads through old app define APIs', async () => {
const { default: SettingDefinePage } = await import('./page');
const html = renderToStaticMarkup(<SettingDefinePage />);
expect(html).toContain('data-client-workbench="true"');
expect(html).toContain('data-setting-define-surface="otlp-cold-define-console"');
- expect(html).toContain('定义');
- expect(html).toContain('cpu-alert');
- expect(html).toContain('http.yml');
- expect(html).toContain('false');
+ expect(html).toContain('data-setting-define-menu-filtered-contract="angular-hide-prometheus-system"');
+ expect(html).toContain('data-setting-define-menu-filtered-owner="setting-define-controller"');
+ expect(html).toContain('data-setting-define-menu-loading-contract="angular-monitor-select-list-loading"');
+ expect(html).toContain('data-setting-define-menu-loading-owner="hertzbeat-ui-template-picker"');
+ expect(html).toContain('data-setting-define-menu-loading="false"');
+ expect(html).toContain('data-setting-define-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-setting-define-load-failure-owner="setting-define-controller"');
+ expect(html).toContain('data-setting-define-load-failure="none"');
+ expect(html).toContain('data-setting-define-theme-switch-contract="angular-nz-switch-code-editor-theme"');
+ expect(html).toContain('data-setting-define-theme-switch-owner="hertzbeat-ui-switch"');
+ expect(html).toContain('data-setting-define-theme-switch-state="vs-dark"');
+ expect(html).toContain('data-setting-define-save-reload-contract="angular-load-app-define-content-after-save"');
+ expect(html).toContain('data-setting-define-save-reload-owner="setting-define-controller"');
+ expect(html).toContain('data-setting-define-save-reload-scope="new-template-draft"');
+ expect(html).toContain('data-setting-define-startup-reload-contract="angular-startup-load-after-success"');
+ expect(html).toContain('data-setting-define-startup-reload-owner="startup-service"');
+ expect(html).toContain('data-setting-define-startup-reload-scope="save-delete-visibility-success"');
+ expect(html).toContain('data-setting-define-startup-reload-failure-contract="angular-fire-and-forget"');
+ expect(html).toContain('data-setting-define-startup-reload-failure-owner="startup-service"');
+ expect(html).toContain('data-setting-define-editor-option-contract="angular-yaml-vs-folding-automatic-layout"');
+ expect(html).toContain('data-setting-define-editor-option-owner="cold-code-editor"');
+ expect(html).toContain('data-setting-define-editor-loading-contract="angular-nz-code-editor-loading"');
+ expect(html).toContain('data-setting-define-editor-loading-owner="cold-code-editor"');
+ expect(html).toContain('data-setting-define-editor-loading="false"');
+ expect(html).toContain('data-setting-define-editor-loading-save-contract="angular-save-hidden-while-editor-loading"');
+ expect(html).toContain('data-setting-define-new-draft-contract="angular-locale-comment-five-newlines"');
+ expect(html).toContain('data-setting-define-new-draft-owner="setting-define-controller"');
+ expect(html).toContain('data-setting-define-new-draft-state="new-template"');
+ expect(html).toContain('data-setting-define-new-action-contract="angular-current-app-reset-url-retained"');
+ expect(html).toContain('data-setting-define-new-action-owner="setting-define-controller"');
+ expect(html).toContain('data-setting-define-new-action-state="hidden"');
+ expect(html).toContain('data-setting-define-menu-select-query-contract="angular-replace-with-app-only"');
+ expect(html).toContain('data-setting-define-menu-select-query-owner="setting-define-page-router"');
+ expect(html).toContain('mysql');
+ expect(html).toContain('app-mysql.yml');
+ expect(html).toContain('app: mysql');
+ expect(html).toContain('data-setting-define-original-yaml="true"');
+ expect(html).toContain('true');
+
+ await mockState.lastLoad?.();
+
+ expect(loadDefineCenterData).toHaveBeenCalledWith(apiMessageGet, null, 'zh-CN');
+ });
+
+ it('keeps the Angular setting define shell visible when menu or YML loading fails', async () => {
+ mockState.renderErrorMessage = 'backend refused define load';
+ const { default: SettingDefinePage } = await import('./page');
+ const html = renderToStaticMarkup(<SettingDefinePage />);
+
+ expect(html).toContain('data-client-workbench="true"');
+ expect(html).toContain('data-setting-define-surface="otlp-cold-define-console"');
+ expect(html).toContain('data-setting-define-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-setting-define-load-failure-owner="setting-define-controller"');
+ expect(html).toContain('data-setting-define-load-failure="angular-console-only-shell"');
+ expect(html).toContain('app-custom.yml');
+ expect(html).toContain('app: custom');
+ expect(html).not.toContain('backend refused define load');
+ expect(html).not.toContain('common.load-failed');
+ expect(html).not.toContain('data-observability-status');
+ expect(mockState.lastSurfaceProps?.data.menuGroups).toEqual([]);
+ expect(mockState.lastSurfaceProps?.loadError).toBe('backend refused define load');
+ });
+
+ it('initializes the YML editor dark toggle from the Angular theme service contract', async () => {
+ const { default: SettingDefinePage } = await import('./page');
+
+ window.localStorage.setItem('theme', 'light-ops');
+ renderToStaticMarkup(<SettingDefinePage />);
+ expect(mockState.lastSurfaceProps?.darkMode).toBe(false);
+
+ document.body.setAttribute('data-theme', 'dark-ops');
+ renderToStaticMarkup(<SettingDefinePage />);
+ expect(mockState.lastSurfaceProps?.darkMode).toBe(true);
+ });
+
+ it('keeps setting define remounts keyed by selected app and reload version', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/define/setting-define-page.tsx'), 'utf8');
+
+ expect(source).toContain('SETTING_DEFINE_SETTLED_CACHE_TTL_MS = 10_000');
+ expect(source).toContain('loadDefineCenterData(apiMessageGet, selectedApp ?? null, locale)');
+ expect(source).toContain('buildNewTemplateDraft');
+ expect(source).toContain('buildFallbackDefineData');
+ expect(source).toContain('renderError={(message) => renderDefineSurface(buildFallbackDefineData(), message)}');
+ expect(source).toContain('loadError={loadError}');
+ expect(source).toContain('menuLoading={false}');
+ expect(source).toContain('editorLoading={false}');
+ expect(source).toContain('reloadTemplateDefinitionStartupContext');
+ expect(source).toContain("if (typeof window === 'undefined') return undefined;");
+ expect(source).toContain('useState<string | null | undefined>(() => readInitialSelectedApp())');
+ expect(source).toContain('writeSelectedAppToUrl(app)');
+ expect(source).toContain("const nextSearch = app ? `?app=${encodeURIComponent(app)}` : '';");
+ expect(source).toContain("window.history.pushState({}, '', `${url.pathname}${nextSearch}`);");
+ expect(source).not.toContain("url.searchParams.set('app', app)");
+ expect(source).toContain('setEditorValue(null)');
+ expect(source).toContain('setOriginalYaml(null)');
+ expect(source).toContain('resolveDefineWorkbenchTheme');
+ expect(source).toContain("window.localStorage.getItem('theme')");
+ expect(source).toContain("document.body?.getAttribute('data-theme')");
+ expect(source).toContain("document.documentElement?.getAttribute('data-theme')");
+ expect(source).toContain("const [darkMode, setDarkMode] = useState(() => readInitialDefineDarkMode());");
+ expect(source).toContain("['setting-define-yml', selectedApp || 'new', reloadVersion].join(':')");
+ expect(source).toContain('[selectedApp, reloadVersion]');
+ expect(source).toContain('[selectedApp, locale]');
+ expect(source).toContain('cacheKey={settingDefineCacheKey}');
+ expect(source).toContain('cacheSettledTtlMs={SETTING_DEFINE_SETTLED_CACHE_TTL_MS}');
+ expect(source).toContain('const activeApp = selectedApp === undefined ? data.selectedApp : selectedApp;');
+ expect(source).not.toContain('const activeApp = selectedApp ?? data.selectedApp;');
+ expect(source).toContain('originalYaml={resolvedOriginalYaml}');
+ expect(source).not.toContain('const [darkMode, setDarkMode] = useState(false);');
+ expect(source).toContain('const [savePending, setSavePending] = useState(false);');
+ expect(source).toContain('const [messageMeta, setMessageMeta] = useState<string | null>(null);');
+ expect(source).toContain('const [messageContract, setMessageContract] = useState<string | null>(null);');
+ expect(source).toContain('function readMutationFailureDetail(error: unknown, fallback = \'\')');
+ expect(source).toContain('savePending={savePending}');
+ expect(source).toContain('messageMeta={messageMeta}');
+ expect(source).toContain('messageContract={messageContract}');
+ expect(source).toContain('setEditorValue(draft.yaml)');
+ expect(source).toContain('setOriginalYaml(draft.originalYaml)');
+ expect(source).not.toContain('writeSelectedAppToUrl(null);');
+ expect(source).toContain('currentYaml === currentOriginalYaml');
+ expect(source).toContain("currentYaml === ''");
+ expect(source).toContain("t('define.save-apply.no-code')");
+ expect(source).toContain("await saveTemplateDefine(apiMessagePost, apiMessagePut, currentYaml, !currentApp);");
+ expect(source).toContain("setMessage(t('common.notify.apply-success'));");
+ expect(source).toContain("setMessage(t('common.notify.apply-fail'));");
+ expect(source).toContain("setMessageMeta(readMutationFailureDetail(error, t('setting.define.save.failed')));");
+ expect(source).toContain("setMessageContract('angular-apply-fail-notification');");
+ expect(source).toContain("setMessage(t('common.notify.delete-fail'));");
+ expect(source).toContain('setMessageMeta(readMutationFailureDetail(error));');
+ expect(source).toContain("setMessageContract('angular-delete-fail-notification');");
+ expect(source).not.toContain('buildMutationFailureMessage(');
+ expect(source).toContain('setEditorValue(null);');
+ expect(source).toContain('setOriginalYaml(null);');
+ expect(source).toContain('refreshStartupContextAfterMutation();');
+ expect(source).toContain('void reloadTemplateDefinitionStartupContext(apiMessageGet, locale).catch(() => undefined);');
+ expect(source).not.toContain("setMessage(t('setting.define.save.success'))");
+ });
+
+ it('re-reads existing app YML from workbench data after a successful save like Angular loadAppDefineContent', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database\n# backend canonical',
+ originalYaml: 'app: mysql\ncategory: database\n# backend canonical'
+ };
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: mysql\ncategory: database\n# backend canonical');
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onEditorValueChange('app: mysql\ncategory: database\n# local edit before save');
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: mysql\ncategory: database\n# local edit before save');
+ expect(mockState.lastSurfaceProps?.isEditing).toBe(true);
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSave();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveTemplateDefine).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ 'app: mysql\ncategory: database\n# local edit before save',
+ false
+ );
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: mysql\ncategory: database\n# backend canonical');
+ expect(mockState.lastSurfaceProps?.originalYaml).toBe('app: mysql\ncategory: database\n# backend canonical');
+ expect(mockState.lastSurfaceProps?.isEditing).toBe(false);
+ expect(reloadTemplateDefinitionStartupContext).toHaveBeenCalledWith(apiMessageGet, 'zh-CN');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('refreshes startup config context after successful YML mutations', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database\n# changed',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ const { default: SettingDefinePage } = await import('./page');
+ const html = renderToStaticMarkup(<SettingDefinePage />);
+ expect(html).toContain('data-setting-define-test-save="true"');
+
+ mockState.lastSurfaceProps?.onSave();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(saveTemplateDefine).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ 'app: mysql\ncategory: database\n# changed',
+ false
+ );
+ expect(reloadTemplateDefinitionStartupContext).toHaveBeenCalledWith(apiMessageGet, 'zh-CN');
+
+ mockState.lastSurfaceProps?.onDelete();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(deleteTemplateDefine).toHaveBeenCalledWith(apiMessageDelete, 'mysql');
+ expect(reloadTemplateDefinitionStartupContext).toHaveBeenCalledTimes(2);
+
+ mockState.lastSurfaceProps?.onToggleTemplateVisibility('mysql', true);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(updateTemplateVisibility).toHaveBeenCalledWith(apiMessagePut, 'mysql', true);
+ expect(reloadTemplateDefinitionStartupContext).toHaveBeenCalledTimes(3);
+ });
+
+ it('keeps mutation success when the Angular startup refresh side effect fails', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database\n# changed',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ reloadTemplateDefinitionStartupContext.mockRejectedValueOnce(new Error('startup refresh down'));
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSave();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveTemplateDefine).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ 'app: mysql\ncategory: database\n# changed',
+ false
+ );
+ expect(reloadTemplateDefinitionStartupContext).toHaveBeenCalledWith(apiMessageGet, 'zh-CN');
+ expect(mockState.lastSurfaceProps?.message).toContain('应用成功');
+ expect(mockState.lastSurfaceProps?.message).not.toContain('startup refresh down');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('keeps the Angular URL app parameter when starting a new draft or after delete success', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql&returnTo=%2Fsettings');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ expect(window.location.search).toContain('app=mysql');
+ expect(window.location.search).toContain('returnTo=%2Fsettings');
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onNew();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.selectedApp).toBeNull();
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: custom\n\n\n\n\n');
+ expect(window.location.search).toContain('app=mysql');
+ expect(window.location.search).toContain('returnTo=%2Fsettings');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+
+ window.history.pushState({}, '', '/setting/define?app=mysql&returnTo=%2Fsettings');
+ const deleteContainer = document.createElement('div');
+ document.body.appendChild(deleteContainer);
+ const deleteRoot = createRoot(deleteContainer);
+
+ await act(async () => {
+ deleteRoot.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDelete();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(deleteTemplateDefine).toHaveBeenCalledWith(apiMessageDelete, 'mysql');
+ expect(mockState.lastSurfaceProps?.selectedApp).toBeNull();
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: custom\n\n\n\n\n');
+ expect(mockState.lastSurfaceProps?.originalYaml).toBe('app: custom');
+ expect(mockState.lastSurfaceProps?.isEditing).toBe(false);
+ expect(window.location.search).toContain('app=mysql');
+ expect(window.location.search).toContain('returnTo=%2Fsettings');
+
+ await act(async () => {
+ deleteRoot.unmount();
+ await Promise.resolve();
+ });
+ deleteContainer.remove();
+ });
+
+ it('navigates selected menu templates through the Angular app query contract', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql&returnTo=%2Fsettings');
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.selectedApp).toBe('mysql');
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSelectApp('linux');
+ await Promise.resolve();
+ });
+
+ expect(window.location.search).toContain('app=linux');
+ expect(window.location.search).toBe('?app=linux');
+ expect(window.location.search).not.toContain('returnTo=%2Fsettings');
+ expect(mockState.lastSurfaceProps?.selectedApp).toBe('linux');
+ expect(mockState.lastSurfaceProps?.isEditing).toBe(false);
await mockState.lastLoad?.();
+ expect(loadDefineCenterData).toHaveBeenLastCalledWith(apiMessageGet, 'linux', 'zh-CN');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('keeps the surface save action busy while save/apply or visibility updates are pending', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database\n# changed',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ let resolveSave: (() => void) | undefined;
+ saveTemplateDefine.mockImplementationOnce(() => new Promise<void>(resolve => {
+ resolveSave = resolve;
+ }));
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.savePending).toBe(false);
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSave();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.savePending).toBe(true);
+
+ await act(async () => {
+ resolveSave?.();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.savePending).toBe(false);
+
+ let resolveVisibility: (() => void) | undefined;
+ updateTemplateVisibility.mockImplementationOnce(() => new Promise<void>(resolve => {
+ resolveVisibility = resolve;
+ }));
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onToggleTemplateVisibility('mysql', true);
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.savePending).toBe(true);
+
+ await act(async () => {
+ resolveVisibility?.();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.savePending).toBe(false);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('preserves the old Angular edit flag when saving a direct new-template YML draft', async () => {
+ const draftYaml = 'app: custom\ncategory: custom\n# still drafting';
+ const draftOriginalYaml = '# 请在此通过编写YML内容来定义新的监控类型';
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: null,
+ yaml: draftYaml,
+ originalYaml: draftOriginalYaml
+ };
+ window.history.pushState({}, '', '/setting/define');
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.selectedApp).toBeNull();
+ expect(mockState.lastSurfaceProps?.isEditing).toBe(false);
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSave();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveTemplateDefine).toHaveBeenCalledWith(apiMessagePost, apiMessagePut, draftYaml, true);
+ expect(mockState.lastSurfaceProps?.selectedApp).toBeNull();
+ expect(mockState.lastSurfaceProps?.editorValue).toBe(draftYaml);
+ expect(mockState.lastSurfaceProps?.originalYaml).toBe(draftOriginalYaml);
+ expect(mockState.lastSurfaceProps?.isEditing).toBe(false);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('keeps old Angular apply-fail title with backend detail when YML save fails', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database\n# invalid',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ saveTemplateDefine.mockRejectedValueOnce(new Error('schema invalid'));
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSave();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveTemplateDefine).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ 'app: mysql\ncategory: database\n# invalid',
+ false
+ );
+ expect(mockState.lastSurfaceProps?.message).toContain('应用失败');
+ expect(mockState.lastSurfaceProps?.message).not.toContain('schema invalid');
+ expect(mockState.lastSurfaceProps?.messageMeta).toBe('schema invalid');
+ expect(mockState.lastSurfaceProps?.messageContract).toBe('angular-apply-fail-notification');
+ expect(reloadTemplateDefinitionStartupContext).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('keeps old Angular delete-fail title with backend detail when YML delete fails', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ deleteTemplateDefine.mockRejectedValueOnce(new Error('template is still in use'));
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDelete();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(deleteTemplateDefine).toHaveBeenCalledWith(apiMessageDelete, 'mysql');
+ expect(mockState.lastSurfaceProps?.message).toContain('删除失败');
+ expect(mockState.lastSurfaceProps?.message).not.toContain('template is still in use');
+ expect(mockState.lastSurfaceProps?.messageMeta).toBe('template is still in use');
+ expect(mockState.lastSurfaceProps?.messageContract).toBe('angular-delete-fail-notification');
+ expect(mockState.lastSurfaceProps?.selectedApp).toBe('mysql');
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: mysql\ncategory: database');
+ expect(mockState.lastSurfaceProps?.originalYaml).toBe('app: mysql\ncategory: database');
+ expect(reloadTemplateDefinitionStartupContext).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('keeps old Angular apply-fail title with backend detail when template visibility update fails', async () => {
+ window.history.pushState({}, '', '/setting/define?app=mysql');
+ mockState.renderData = {
+ menuGroups: [
+ {
+ key: 'database',
+ label: 'DATABASE',
+ items: [{ category: 'database', value: 'mysql', label: 'MySQL', hide: false }]
+ }
+ ],
+ appLabels: { mysql: 'MySQL' },
+ selectedApp: 'mysql',
+ yaml: 'app: mysql\ncategory: database',
+ originalYaml: 'app: mysql\ncategory: database'
+ };
+ updateTemplateVisibility.mockRejectedValueOnce(new Error('visibility locked by policy'));
+ const { default: SettingDefinePage } = await import('./page');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingDefinePage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onToggleTemplateVisibility('mysql', true);
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(updateTemplateVisibility).toHaveBeenCalledWith(apiMessagePut, 'mysql', true);
+ expect(mockState.lastSurfaceProps?.message).toContain('应用失败');
+ expect(mockState.lastSurfaceProps?.message).not.toContain('visibility locked by policy');
+ expect(mockState.lastSurfaceProps?.messageMeta).toBe('visibility locked by policy');
+ expect(mockState.lastSurfaceProps?.messageContract).toBe('angular-apply-fail-notification');
+ expect(mockState.lastSurfaceProps?.selectedApp).toBe('mysql');
+ expect(mockState.lastSurfaceProps?.editorValue).toBe('app: mysql\ncategory: database');
+ expect(reloadTemplateDefinitionStartupContext).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ });
+
+ it('does not depend on alert definition preview or datasource APIs', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/define/setting-define-page.tsx'), 'utf8');
- expect(apiMessageGet).toHaveBeenNthCalledWith(1, '/alert/defines?pageIndex=0&pageSize=8&sort=id&order=desc');
- expect(apiMessageGet).toHaveBeenNthCalledWith(2, '/alert/define/datasource/status');
+ expect(source).toContain('saveTemplateDefine');
+ expect(source).toContain('deleteTemplateDefine');
+ expect(source).toContain('updateTemplateVisibility');
+ expect(source).toContain('refreshStartupContextAfterMutation');
+ expect(source).toContain('.catch(() => undefined)');
+ expect(source).toContain('apiMessageDelete');
+ expect(source).not.toContain('/alert/define');
+ expect(source).not.toContain('buildPreviewUrl');
+ expect(source).not.toContain('buildSkeletonDefine');
});
});
diff --git a/web-next/app/setting/define/page.tsx b/web-next/app/setting/define/page.tsx
index eb4f0480b5..4a44cd227b 100644
--- a/web-next/app/setting/define/page.tsx
+++ b/web-next/app/setting/define/page.tsx
@@ -1,152 +1,7 @@
-'use client';
+import React from 'react';
-import { useCallback, useState } from 'react';
-import { ClientWorkbench } from '@/components/workbench/client-workbench';
-import { SettingDefineSurface } from '@/components/pages/setting-define-surface';
-import { useI18n } from '@/components/providers/i18n-provider';
-import { apiGet, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
-import { formatTime } from '@/lib/format';
-import { buildPreviewUrl, buildSkeletonDefine, loadDefineCenterData, saveDefine } from '@/lib/setting-define/controller';
-import type { AlertDefine } from '@/lib/types';
+import SettingDefinePage from './setting-define-page';
-function buildDefineEditorValue(define: AlertDefine | null) {
- const draft = define || buildSkeletonDefine();
- return [
- `name: ${draft.name || ''}`,
- `type: ${draft.type || ''}`,
- `datasource: ${draft.datasource || ''}`,
- `enable: ${draft.enable === false ? 'false' : 'true'}`,
- `period: ${draft.period || 0}`,
- `times: ${draft.times || 0}`,
- 'expr: |',
- ` ${(draft.expr || '').replace(/\n/g, '\n ')}`
- ].join('\n');
-}
-
-function buildCurrentYamlLabel(define: AlertDefine | null) {
- if (!define) return null;
- const stem = define.datasource || define.name || 'current';
- return `${stem}.yml`;
-}
-
-function cloneDefine(define: AlertDefine) {
- return {
- ...define,
- labels: define.labels ? { ...define.labels } : define.labels,
- annotations: define.annotations ? { ...define.annotations } : define.annotations
- };
-}
-
-export default function SettingDefinePage() {
- const { t } = useI18n();
- const [search, setSearch] = useState('');
- const [query, setQuery] = useState('');
- const [selected, setSelected] = useState<AlertDefine | null>(null);
- const [preview, setPreview] = useState<unknown>(null);
- const [message, setMessage] = useState<string | null>(null);
- const [saving, setSaving] = useState(false);
- const [editorValue, setEditorValue] = useState('');
- const [darkMode, setDarkMode] = useState(false);
- const [isEditing, setIsEditing] = useState(false);
-
- const load = useCallback(async () => {
- return loadDefineCenterData(apiMessageGet, query);
- }, [query]);
-
- async function previewDefine(current: AlertDefine | null) {
- if (!current?.datasource || !current?.expr) return;
- setMessage(null);
- try {
- const result = await apiGet<{ code: number; data?: unknown; msg?: string }>(buildPreviewUrl(current));
- setPreview(result.data);
- setMessage(result.code === 0 ? t('setting.define.preview.success') : result.msg || t('setting.define.preview.failed'));
- } catch (error) {
- setPreview(null);
- setMessage(error instanceof Error ? error.message : t('setting.define.preview.failed'));
- }
- }
-
- async function saveSelected(current: AlertDefine | null) {
- if (!current) return;
- setSaving(true);
- setMessage(null);
- try {
- if (current.id && current.id > 0) {
- await saveDefine(apiMessagePut, current);
- } else {
- await apiMessagePost('/alert/define', current);
- }
- setMessage(t('setting.define.save.success'));
- setIsEditing(false);
- } catch (error) {
- setMessage(error instanceof Error ? error.message : t('setting.define.save.failed'));
- } finally {
- setSaving(false);
- }
- }
-
- return (
- <ClientWorkbench load={load} loadingCopy={t('setting.define.loading')}>
- {data => {
- const activeDefine = selected ?? data.list.content[0] ?? null;
- const resolvedEditorValue = editorValue || buildDefineEditorValue(activeDefine);
-
- return (
- <SettingDefineSurface
- t={t}
- data={data}
- search={search}
- selectedDefine={activeDefine}
- editorValue={resolvedEditorValue}
- yamlLabel={buildCurrentYamlLabel(activeDefine)}
- darkMode={darkMode}
- isEditing={isEditing}
- formatTime={formatTime}
- message={message}
- preview={preview}
- onSearchChange={setSearch}
- onSearch={() => setQuery(search)}
- onSelectDefine={define => {
- setSelected(cloneDefine(define));
- setEditorValue(buildDefineEditorValue(define));
- setMessage(null);
- setPreview(null);
- setIsEditing(false);
- }}
- onNew={() => {
- const skeleton = buildSkeletonDefine();
- setSelected(skeleton);
- setEditorValue(buildDefineEditorValue(skeleton));
- setMessage(null);
- setPreview(null);
- setIsEditing(true);
- }}
- onEdit={() => {
- if (activeDefine) {
- setSelected(cloneDefine(activeDefine));
- setEditorValue(resolvedEditorValue);
- setIsEditing(true);
- }
- }}
- onCancel={() => {
- setEditorValue(buildDefineEditorValue(activeDefine));
- setMessage(null);
- setPreview(null);
- setIsEditing(false);
- }}
- onSave={() => void saveSelected(activeDefine)}
- onDelete={() => setMessage(t('define.delete', { app: activeDefine?.name || 'current' }))}
- onToggleDarkMode={setDarkMode}
- onPreview={() => void previewDefine(activeDefine)}
- onEditorValueChange={value => {
- setEditorValue(value);
- if (!isEditing) {
- setIsEditing(true);
- }
- }}
- />
- );
- }}
- </ClientWorkbench>
- );
+export default function SettingDefineRoutePage() {
+ return <SettingDefinePage />;
}
diff --git a/web-next/app/setting/define/setting-define-page.tsx b/web-next/app/setting/define/setting-define-page.tsx
new file mode 100644
index 0000000000..daa9b32f8f
--- /dev/null
+++ b/web-next/app/setting/define/setting-define-page.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useCallback, useMemo, useState } from 'react';
+import { ClientWorkbench } from '@/components/workbench/client-workbench';
+import { SettingDefineSurface } from '@/components/pages/setting-define-surface';
+import { useI18n } from '@/components/providers/i18n-provider';
+import { apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
+import {
+ buildNewTemplateDraft,
+ deleteTemplateDefine,
+ loadDefineCenterData,
+ reloadTemplateDefinitionStartupContext,
+ saveTemplateDefine,
+ updateTemplateVisibility,
+ type SettingDefinePageData
+} from '@/lib/setting-define/controller';
+import { readSettingDefineRouteStateFromSearch } from '@/lib/setting-define/query-state';
+
+const SETTING_DEFINE_SETTLED_CACHE_TTL_MS = 10_000;
+
+function readInitialSelectedApp() {
+ if (typeof window === 'undefined') return undefined;
+ return readSettingDefineRouteStateFromSearch(window.location.search).app;
+}
+
+function writeSelectedAppToUrl(app: string | null) {
+ if (typeof window === 'undefined') return;
+ const url = new URL(window.location.href);
+ const nextSearch = app ? `?app=${encodeURIComponent(app)}` : '';
+ window.history.pushState({}, '', `${url.pathname}${nextSearch}`);
+}
+
+function buildYamlLabel(app: string | null, yaml: string) {
+ if (app) return `app-${app}.yml`;
+ const match = yaml.match(/^\s*app:\s*([^\s#]+)/m);
+ return `app-${match?.[1]?.trim() || 'custom'}.yml`;
+}
+
+function resolveDefineWorkbenchTheme(theme: string | null | undefined): 'dark-ops' | 'light-ops' {
+ return theme === 'light-ops' || theme === 'default' || theme === 'compact' ? 'light-ops' : 'dark-ops';
+}
+
+function readMutationFailureDetail(error: unknown, fallback = '') {
+ if (error instanceof Error) return error.message;
+ return fallback;
+}
+
+function readStoredTheme() {
+ if (typeof window === 'undefined') return null;
+ try {
+ return window.localStorage.getItem('theme');
+ } catch {
+ return null;
+ }
+}
+
+function readInitialDefineDarkMode() {
+ if (typeof document === 'undefined') return false;
+ const domTheme = document.body?.getAttribute('data-theme') || document.documentElement?.getAttribute('data-theme');
+ const theme = resolveDefineWorkbenchTheme(domTheme || readStoredTheme() || 'dark-ops');
+ return theme === 'dark-ops';
+}
+
+export default function SettingDefinePage() {
+ const { locale, t } = useI18n();
+ const [search, setSearch] = useState('');
+ const [selectedApp, setSelectedApp] = useState<string | null | undefined>(() => readInitialSelectedApp());
+ const [message, setMessage] = useState<string | null>(null);
+ const [messageMeta, setMessageMeta] = useState<string | null>(null);
+ const [messageContract, setMessageContract] = useState<string | null>(null);
+ const [editorValue, setEditorValue] = useState<string | null>(null);
+ const [originalYaml, setOriginalYaml] = useState<string | null>(null);
+ const [darkMode, setDarkMode] = useState(() => readInitialDefineDarkMode());
+ const [isEditing, setIsEditing] = useState(false);
+ const [savePending, setSavePending] = useState(false);
+ const [reloadVersion, setReloadVersion] = useState(0);
+ const settingDefineCacheKey = useMemo(
+ () => ['setting-define-yml', selectedApp || 'new', reloadVersion].join(':'),
+ [selectedApp, reloadVersion]
+ );
+
+ const load = useCallback(async () => {
+ return loadDefineCenterData(apiMessageGet, selectedApp ?? null, locale);
+ }, [selectedApp, locale]);
+
+ const refreshStartupContextAfterMutation = useCallback(() => {
+ void reloadTemplateDefinitionStartupContext(apiMessageGet, locale).catch(() => undefined);
+ }, [locale]);
+
+ const clearMutationMessage = useCallback(() => {
+ setMessage(null);
+ setMessageMeta(null);
+ setMessageContract(null);
+ }, []);
+
+ const buildFallbackDefineData = useCallback((): SettingDefinePageData => {
+ const draft = buildNewTemplateDraft(locale);
+ return {
+ menuGroups: [],
+ appLabels: {},
+ selectedApp: selectedApp ?? null,
+ yaml: draft.yaml,
+ originalYaml: draft.originalYaml
+ };
+ }, [locale, selectedApp]);
+
+ function selectApp(app: string) {
+ setSelectedApp(app);
+ writeSelectedAppToUrl(app);
+ setEditorValue(null);
+ setOriginalYaml(null);
+ clearMutationMessage();
+ setIsEditing(false);
+ }
+
+ function startNewTemplate() {
+ const draft = buildNewTemplateDraft(locale);
+ setSelectedApp(null);
+ setEditorValue(draft.yaml);
+ setOriginalYaml(draft.originalYaml);
+ clearMutationMessage();
+ setIsEditing(true);
+ }
+
+ async function saveCurrent(currentApp: string | null, currentYaml: string, currentOriginalYaml: string) {
+ if (currentYaml === currentOriginalYaml) return;
+ if (currentYaml === '') {
+ setMessage(t('define.save-apply.no-code'));
+ setMessageMeta(null);
+ setMessageContract(null);
+ return;
+ }
+ clearMutationMessage();
+ setSavePending(true);
+ try {
+ await saveTemplateDefine(apiMessagePost, apiMessagePut, currentYaml, !currentApp);
+ setMessage(t('common.notify.apply-success'));
+ setMessageMeta(null);
+ setMessageContract(null);
+ if (currentApp) {
+ setEditorValue(null);
+ setOriginalYaml(null);
+ setIsEditing(false);
+ } else {
+ setOriginalYaml(currentOriginalYaml);
+ }
+ setReloadVersion(version => version + 1);
+ refreshStartupContextAfterMutation();
+ } catch (error) {
+ setMessage(t('common.notify.apply-fail'));
+ setMessageMeta(readMutationFailureDetail(error, t('setting.define.save.failed')));
+ setMessageContract('angular-apply-fail-notification');
+ } finally {
+ setSavePending(false);
+ }
+ }
+
+ async function deleteCurrent(currentApp: string | null) {
+ if (!currentApp) return;
+ clearMutationMessage();
+ try {
+ await deleteTemplateDefine(apiMessageDelete, currentApp);
+ const draft = buildNewTemplateDraft(locale);
+ setSelectedApp(null);
+ setEditorValue(draft.yaml);
+ setOriginalYaml(draft.originalYaml);
+ setMessage(t('common.notify.delete-success'));
+ setMessageMeta(null);
+ setMessageContract(null);
+ setReloadVersion(version => version + 1);
+ refreshStartupContextAfterMutation();
+ } catch (error) {
+ setMessage(t('common.notify.delete-fail'));
+ setMessageMeta(readMutationFailureDetail(error));
+ setMessageContract('angular-delete-fail-notification');
+ }
+ }
+
+ async function toggleTemplateVisibility(app: string, hide: boolean) {
+ clearMutationMessage();
+ setSavePending(true);
+ try {
+ await updateTemplateVisibility(apiMessagePut, app, hide);
+ setMessage(t('common.notify.apply-success'));
+ setMessageMeta(null);
+ setMessageContract(null);
+ setReloadVersion(version => version + 1);
+ refreshStartupContextAfterMutation();
+ } catch (error) {
+ setMessage(t('common.notify.apply-fail'));
+ setMessageMeta(readMutationFailureDetail(error));
+ setMessageContract('angular-apply-fail-notification');
+ } finally {
+ setSavePending(false);
+ }
+ }
+
+ function renderDefineSurface(data: SettingDefinePageData, loadError: string | null = null) {
+ const activeApp = selectedApp === undefined ? data.selectedApp : selectedApp;
+ const resolvedEditorValue = editorValue ?? data.yaml;
+ const resolvedOriginalYaml = originalYaml ?? data.originalYaml;
+
+ return (
+ <SettingDefineSurface
+ t={t}
+ data={data}
+ search={search}
+ selectedApp={activeApp}
+ editorValue={resolvedEditorValue}
+ originalYaml={resolvedOriginalYaml}
+ yamlLabel={buildYamlLabel(activeApp, resolvedEditorValue)}
+ darkMode={darkMode}
+ isEditing={isEditing}
+ menuLoading={false}
+ editorLoading={false}
+ savePending={savePending}
+ message={message}
+ messageMeta={messageMeta}
+ messageContract={messageContract}
+ loadError={loadError}
+ onSearchChange={setSearch}
+ onSearch={() => undefined}
+ onSelectApp={selectApp}
+ onNew={startNewTemplate}
+ onEdit={() => setIsEditing(true)}
+ onCancel={() => {
+ setEditorValue(resolvedOriginalYaml);
+ clearMutationMessage();
+ setIsEditing(false);
+ }}
+ onSave={() => void saveCurrent(activeApp, resolvedEditorValue, resolvedOriginalYaml)}
+ onDelete={() => void deleteCurrent(activeApp)}
+ onToggleTemplateVisibility={(app, hide) => void toggleTemplateVisibility(app, hide)}
+ onToggleDarkMode={setDarkMode}
+ onEditorValueChange={value => {
+ setEditorValue(value);
+ clearMutationMessage();
+ if (!isEditing) {
+ setIsEditing(true);
+ }
+ }}
+ />
+ );
+ }
+
+ return (
+ <ClientWorkbench
+ load={load}
+ loadingCopy={t('setting.define.loading')}
+ cacheKey={settingDefineCacheKey}
+ cacheSettledTtlMs={SETTING_DEFINE_SETTLED_CACHE_TTL_MS}
+ renderError={(message) => renderDefineSurface(buildFallbackDefineData(), message)}
+ >
+ {data => renderDefineSurface(data)}
+ </ClientWorkbench>
+ );
+}
diff --git a/web-next/app/setting/labels/page.test.tsx b/web-next/app/setting/labels/page.test.tsx
index 22e16e93e5..e90db4cb8e 100644
--- a/web-next/app/setting/labels/page.test.tsx
+++ b/web-next/app/setting/labels/page.test.tsx
@@ -1,14 +1,22 @@
+// @vitest-environment jsdom
+
import React from 'react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTranslatorMock } from '../../../test/i18n-test-helper';
(globalThis as { React?: typeof React }).React = React;
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockState = vi.hoisted(() => ({
lastLoad: null as null | (() => Promise<unknown>),
+ lastRetry: null as null | (() => void),
+ renderErrorMessage: null as string | null,
+ lastSurfaceProps: null as null | Record<string, any>,
renderData: {
list: {
content: [
@@ -32,14 +40,14 @@ const apiMessagePost = vi.hoisted(() => vi.fn());
const apiMessagePut = vi.hoisted(() => vi.fn());
const apiMessageDelete = vi.hoisted(() => vi.fn());
const loadLabelData = vi.hoisted(() => vi.fn());
+const saveLabel = vi.hoisted(() => vi.fn());
+const deleteLabel = vi.hoisted(() => vi.fn());
+let SettingLabelsPage: React.ComponentType;
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
t: createTranslatorMock({
- locale: 'zh-CN',
- overrides: {
- 'setting.labels.loading': '正在加载标签'
- }
+ locale: 'zh-CN'
})
})
}));
@@ -47,30 +55,136 @@ vi.mock('@/components/providers/i18n-provider', () => ({
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
- load
+ load,
+ renderError
}: {
children: (data: unknown) => React.ReactNode;
load: () => Promise<unknown>;
+ renderError?: (message: string, retry: () => void) => React.ReactNode;
}) => {
mockState.lastLoad = load;
+ if (mockState.renderErrorMessage && renderError) {
+ const retry = vi.fn();
+ mockState.lastRetry = retry;
+ return <div data-client-workbench="true">{renderError(mockState.renderErrorMessage, retry)}</div>;
+ }
return <div data-client-workbench="true">{children(mockState.renderData)}</div>;
}
}));
vi.mock('@/components/pages/label-manage-surface', () => ({
- LabelManageSurface: ({ search, draftLabel, isManageModalAdd }: any) => (
+ LabelManageSurface: (props: any) => {
+ mockState.lastSurfaceProps = props;
+ const {
+ data,
+ search,
+ draftLabel,
+ isManageModalAdd,
+ isNameValidationVisible,
+ deleteTarget,
+ isLoadPending,
+ isDeletePending,
+ isSavePending,
+ actionMessage,
+ actionError,
+ actionMeta,
+ loadError,
+ onSearchClear,
+ onEdit,
+ onDraftChange,
+ onCloseDialog
+ } = props;
+ return (
<div
data-label-manage-surface="otlp-cold-label-console"
data-label-manage-style-baseline="hertzbeat-cold-matte"
+ data-label-action-feedback={actionError ? 'angular-action-error' : actionMessage ? 'angular-action-success' : 'none'}
+ data-label-save-feedback={actionError ? 'angular-save-error' : actionMessage ? 'angular-save-success' : 'none'}
+ data-label-action-meta={actionMeta ?? 'no-action-meta'}
+ data-label-save-feedback-contract="angular-new-edit-notify"
+ data-label-save-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-label-save-failure-contract="angular-new-edit-fail-notification"
+ data-label-save-failure-owner="hertzbeat-ui-inline-feedback"
+ data-label-load-failure-contract="angular-console-only-shell"
+ data-label-load-failure-owner="label-route-controller"
+ data-label-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-label-copy-feedback-contract="angular-copy-notify"
+ data-label-copy-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-label-delete-confirm-contract="angular-modal-confirm"
+ data-label-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-label-delete-confirm-state-contract="angular-modal-confirm-state"
+ data-label-delete-confirm-state-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-label-delete-confirm-state={deleteTarget ? 'open' : 'closed'}
+ data-label-delete-confirm-close-contract="angular-close-before-delete-result"
+ data-label-delete-confirm-close-owner="route-state-contract"
+ data-label-delete-feedback-contract="angular-delete-notify"
+ data-label-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-label-delete-failure-contract="angular-delete-fail-notification"
+ data-label-delete-failure-owner="hertzbeat-ui-inline-feedback"
+ data-label-delete-query-contract="angular-repeated-ids-query"
+ data-label-delete-query-owner="route-mutation-contract"
+ data-label-card-loading-contract="angular-table-loading"
+ data-label-card-loading-owner="label-route-controller"
+ data-label-card-loading={isLoadPending || isDeletePending ? 'true' : 'false'}
+ data-label-save-loading-contract="angular-nz-ok-loading"
+ data-label-save-loading-contract-owner="angular-nz-ok-loading"
+ data-label-save-trim-contract="angular-trim-before-save"
+ data-label-save-trim-owner="label-manage-controller"
+ data-label-optional-save-payload-contract="angular-preserve-undefined-optional-fields"
+ data-label-optional-save-payload-owner="label-manage-controller"
+ data-label-type-save-payload-contract="angular-preserve-implicit-type"
+ data-label-type-save-payload-owner="label-manage-controller"
+ data-label-description-display-contract="angular-truthy-description-raw"
+ data-label-description-display-owner="label-manage-surface"
+ data-label-dialog-mask-closable-contract="angular-mask-closable-false"
+ data-label-dialog-mask-closable-owner="angular-nz-modal"
+ data-label-dialog-width-contract="angular-width-30-percent"
+ data-label-dialog-width-owner="angular-nz-modal"
+ data-label-dialog-field-layout-contract="angular-label-7-control-12"
+ data-label-dialog-field-layout-owner="route-form-field-grid"
+ data-label-dialog-preview-visibility-contract="angular-name-defined-preview"
+ data-label-dialog-preview-visibility-owner="route-form-state"
+ data-label-dialog-preview-frame-contract="angular-inline-form-item"
+ data-label-dialog-preview-frame-owner="route-form-field-grid"
+ data-label-edit-reference-contract="angular-edit-direct-reference"
+ data-label-edit-reference-owner="route-form-state"
+ data-label-save-ok-loading={isSavePending ? 'true' : 'false'}
+ data-label-save-ok-loading-owner="angular-nz-ok-loading"
+ data-label-name-validation-contract="angular-required-before-submit"
+ data-label-name-validation-trigger="angular-ok-marks-dirty"
+ data-label-name-validation-trigger-owner="route-form-contract"
+ data-label-name-validation-raw-contract="angular-required-before-trim"
+ data-label-name-validation-raw-owner="route-form-contract"
+ data-label-name-validation-visible={isNameValidationVisible ? 'true' : 'false'}
+ data-label-query-contract="angular-load-all-labels"
+ data-label-query-owner="label-query-state"
+ data-label-query-param-order-contract="angular-page-index-size-type-search"
+ data-label-query-param-order-owner="label-query-state"
+ data-label-search-submit="angular-enter-and-clear"
+ data-label-search-submit-owner="cold-search-row"
+ data-label-search-clear-wired={typeof onSearchClear === 'function' ? 'true' : 'false'}
+ data-label-card-grid-contract="angular-card-grid"
+ data-label-card-grid-owner="hertzbeat-ui-label-tag"
+ data-label-monitor-handoff-contract="angular-routerlink-monitors-labels"
+ data-label-monitor-handoff-owner="next-monitor-query-link"
>
<section data-label-admin-layout="full-width-admin-list">
<span>标签管理</span>
<span>{search}</span>
+ <span data-label-test-first-name="true">{data.list.content[0]?.name || 'no-row'}</span>
<span>{draftLabel ? draftLabel.name : 'no-draft'}</span>
<span>{String(isManageModalAdd)}</span>
+ <span>{deleteTarget ? deleteTarget.name : 'no-delete-target'}</span>
+ <span>{actionMessage || actionError || 'no-feedback'}</span>
+ <span>{actionMeta || 'no-meta'}</span>
+ <span>{loadError || 'no-load-error'}</span>
+ <button data-label-test-edit="true" onClick={() => onEdit(data.list.content[0])}>edit</button>
+ <button data-label-test-change-name="true" onClick={() => onDraftChange({ name: 'platform' })}>change name</button>
+ <button data-label-test-close="true" onClick={onCloseDialog}>close</button>
</section>
</div>
- )
+ );
+ }
}));
vi.mock('@/lib/api-client', () => ({
@@ -86,27 +200,52 @@ vi.mock('@/lib/format', () => ({
vi.mock('@/lib/label-manage/controller', () => ({
loadLabelData,
- createEmptyLabelDraft: () => ({ id: 0, name: '', tagValue: '', description: '', type: 1 }),
+ createEmptyLabelDraft: () => ({ id: 0, name: undefined, tagValue: undefined, description: undefined, type: undefined }),
cloneLabelDraft: (label: any) => ({ ...label }),
- saveLabel: vi.fn(),
- deleteLabel: vi.fn()
+ saveLabel,
+ deleteLabel
}));
describe('setting labels page', () => {
+ beforeAll(async () => {
+ const pageModule = await import('./page');
+ SettingLabelsPage = pageModule.default;
+ }, 30000);
+
beforeEach(() => {
mockState.lastLoad = null;
+ mockState.lastRetry = null;
+ mockState.renderErrorMessage = null;
+ mockState.lastSurfaceProps = null;
+ mockState.renderData = {
+ list: {
+ content: [
+ {
+ id: 1,
+ name: 'team',
+ tagValue: 'ops',
+ description: 'ops team',
+ type: 1
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ };
apiMessageGet.mockReset();
apiMessagePost.mockReset();
apiMessagePut.mockReset();
apiMessageDelete.mockReset();
+ saveLabel.mockReset().mockResolvedValue(undefined);
+ deleteLabel.mockReset().mockResolvedValue(undefined);
loadLabelData.mockReset().mockImplementation(async (apiGetFn, query) => {
- await apiGetFn(`/label?pageIndex=0&pageSize=8${query.search ? `&search=${query.search}` : ''}`);
+ await apiGetFn(`/label?pageIndex=0&pageSize=9999${query.search ? `&search=${query.search}` : ''}`);
return mockState.renderData;
});
});
it('renders the shared label surface and keeps the route focused on load composition', async () => {
- const { default: SettingLabelsPage } = await import('./page');
const html = renderToStaticMarkup(<SettingLabelsPage />);
expect(html).toContain('data-client-workbench="true"');
@@ -119,16 +258,156 @@ describe('setting labels page', () => {
expect(html).toContain('标签管理');
expect(html).toContain('no-draft');
expect(html).toContain('false');
+ expect(html).toContain('data-label-save-feedback="none"');
+ expect(html).toContain('data-label-save-feedback-contract="angular-new-edit-notify"');
+ expect(html).toContain('data-label-save-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-save-failure-contract="angular-new-edit-fail-notification"');
+ expect(html).toContain('data-label-save-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-label-load-failure-owner="label-route-controller"');
+ expect(html).toContain('data-label-load-failure="none"');
+ expect(html).toContain('data-label-copy-feedback-contract="angular-copy-notify"');
+ expect(html).toContain('data-label-copy-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-label-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-label-delete-confirm-state-contract="angular-modal-confirm-state"');
+ expect(html).toContain('data-label-delete-confirm-state-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-label-delete-confirm-state="closed"');
+ expect(html).toContain('data-label-delete-confirm-close-contract="angular-close-before-delete-result"');
+ expect(html).toContain('data-label-delete-confirm-close-owner="route-state-contract"');
+ expect(html).toContain('data-label-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-label-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-delete-failure-contract="angular-delete-fail-notification"');
+ expect(html).toContain('data-label-delete-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-delete-query-contract="angular-repeated-ids-query"');
+ expect(html).toContain('data-label-delete-query-owner="route-mutation-contract"');
+ expect(html).toContain('data-label-card-loading-contract="angular-table-loading"');
+ expect(html).toContain('data-label-card-loading-owner="label-route-controller"');
+ expect(html).toContain('data-label-card-loading="false"');
+ expect(html).toContain('data-label-dialog-field-layout-contract="angular-label-7-control-12"');
+ expect(html).toContain('data-label-dialog-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-label-dialog-preview-visibility-contract="angular-name-defined-preview"');
+ expect(html).toContain('data-label-dialog-preview-visibility-owner="route-form-state"');
+ expect(html).toContain('data-label-dialog-preview-frame-contract="angular-inline-form-item"');
+ expect(html).toContain('data-label-dialog-preview-frame-owner="route-form-field-grid"');
+ expect(html).toContain('data-label-edit-reference-contract="angular-edit-direct-reference"');
+ expect(html).toContain('data-label-edit-reference-owner="route-form-state"');
+ expect(html).toContain('data-label-save-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-label-save-loading-contract-owner="angular-nz-ok-loading"');
+ expect(html).toContain('data-label-save-trim-contract="angular-trim-before-save"');
+ expect(html).toContain('data-label-save-trim-owner="label-manage-controller"');
+ expect(html).toContain('data-label-optional-save-payload-contract="angular-preserve-undefined-optional-fields"');
+ expect(html).toContain('data-label-optional-save-payload-owner="label-manage-controller"');
+ expect(html).toContain('data-label-type-save-payload-contract="angular-preserve-implicit-type"');
+ expect(html).toContain('data-label-type-save-payload-owner="label-manage-controller"');
+ expect(html).toContain('data-label-dialog-mask-closable-contract="angular-mask-closable-false"');
+ expect(html).toContain('data-label-dialog-mask-closable-owner="angular-nz-modal"');
+ expect(html).toContain('data-label-dialog-width-contract="angular-width-30-percent"');
+ expect(html).toContain('data-label-dialog-width-owner="angular-nz-modal"');
+ expect(html).toContain('data-label-save-ok-loading="false"');
+ expect(html).toContain('data-label-save-ok-loading-owner="angular-nz-ok-loading"');
+ expect(html).toContain('data-label-name-validation-contract="angular-required-before-submit"');
+ expect(html).toContain('data-label-name-validation-trigger="angular-ok-marks-dirty"');
+ expect(html).toContain('data-label-name-validation-trigger-owner="route-form-contract"');
+ expect(html).toContain('data-label-name-validation-raw-contract="angular-required-before-trim"');
+ expect(html).toContain('data-label-name-validation-raw-owner="route-form-contract"');
+ expect(html).toContain('data-label-name-validation-visible="false"');
+ expect(html).toContain('data-label-query-contract="angular-load-all-labels"');
+ expect(html).toContain('data-label-query-owner="label-query-state"');
+ expect(html).toContain('data-label-query-param-order-contract="angular-page-index-size-type-search"');
+ expect(html).toContain('data-label-query-param-order-owner="label-query-state"');
+ expect(html).toContain('data-label-search-submit="angular-enter-and-clear"');
+ expect(html).toContain('data-label-search-submit-owner="cold-search-row"');
+ expect(html).toContain('data-label-search-clear-wired="true"');
+ expect(html).toContain('data-label-card-grid-contract="angular-card-grid"');
+ expect(html).toContain('data-label-card-grid-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('data-label-monitor-handoff-contract="angular-routerlink-monitors-labels"');
+ expect(html).toContain('data-label-monitor-handoff-owner="next-monitor-query-link"');
+ expect(html).toContain('no-delete-target');
+ expect(html).toContain('no-feedback');
+ expect(html).toContain('no-meta');
+ expect(html).toContain('no-load-error');
await mockState.lastLoad?.();
- expect(apiMessageGet).toHaveBeenCalledWith('/label?pageIndex=0&pageSize=8');
+ expect(apiMessageGet).toHaveBeenCalledWith('/label?pageIndex=0&pageSize=9999');
+ }, 30000);
+
+ it('keeps the Angular label shell visible when list loading fails', () => {
+ mockState.renderErrorMessage = 'backend refused label load';
+
+ const html = renderToStaticMarkup(<SettingLabelsPage />);
+
+ expect(html).toContain('data-client-workbench="true"');
+ expect(html).toContain('data-label-manage-surface="otlp-cold-label-console"');
+ expect(html).toContain('data-label-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-label-load-failure-owner="label-route-controller"');
+ expect(html).toContain('data-label-load-failure="angular-console-only-shell"');
+ expect(html).toContain('backend refused label load');
+ expect(html).toContain('data-label-card-loading="false"');
+ expect(html).toContain('data-label-save-feedback="none"');
+ expect(html).toContain('data-label-test-first-name="true">no-row');
+ expect(html).not.toContain('data-observability-status');
+ expect(html).not.toContain('common.load-failed');
+ expect(mockState.lastRetry).toBeTypeOf('function');
});
+ it('keeps the Angular edit direct-reference behavior when canceling the label dialog', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingLabelsPage />);
+ await Promise.resolve();
+ });
+
+ expect(container.querySelector('[data-label-test-first-name="true"]')?.textContent).toBe('team');
+ expect(mockState.lastSurfaceProps?.draftLabel).toBeNull();
+
+ await act(async () => {
+ (container.querySelector('[data-label-test-edit="true"]') as HTMLButtonElement).click();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.draftLabel).toBe(mockState.renderData.list.content[0]);
+ expect(mockState.lastSurfaceProps?.draftLabel?.name).toBe('team');
+
+ await act(async () => {
+ (container.querySelector('[data-label-test-change-name="true"]') as HTMLButtonElement).click();
+ await Promise.resolve();
+ });
+
+ expect(mockState.renderData.list.content[0].name).toBe('platform');
+ expect(container.querySelector('[data-label-test-first-name="true"]')?.textContent).toBe('platform');
+ expect(mockState.lastSurfaceProps?.draftLabel?.name).toBe('platform');
+
+ await act(async () => {
+ (container.querySelector('[data-label-test-close="true"]') as HTMLButtonElement).click();
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.draftLabel).toBeNull();
+ expect(mockState.renderData.list.content[0].name).toBe('platform');
+ expect(container.querySelector('[data-label-test-first-name="true"]')?.textContent).toBe('platform');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ }, 30000);
+
it('keeps the route thin and leaves cold label visuals in the shared surface', () => {
- const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/page.tsx'), 'utf8');
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
expect(source).toContain('LabelManageSurface');
+ expect(source).toContain('EMPTY_LABEL_DATA');
+ expect(source).toContain('renderError={(message, retry) => renderSurface(EMPTY_LABEL_DATA, message, retry)}');
+ expect(source).toContain('buildLabelUrl(query)');
+ expect(source).toContain('setDraftLabel(label)');
+ expect(source).toContain('Object.assign(sourceLabel, patch)');
+ expect(source).not.toContain('cloneLabelDraft');
expect(source).not.toContain('WorkbenchPage');
expect(source).not.toContain('SurfaceSection');
expect(source).not.toContain('ToolbarRow');
@@ -137,4 +416,297 @@ describe('setting labels page', () => {
expect(source).not.toContain('data-label-toolbar');
expect(source).not.toContain('otlp-cold-label-console');
});
+
+ it('keeps label management remounts on a short settled cache window while reloads invalidate it', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
+
+ expect(source).toContain('SETTING_LABELS_SETTLED_CACHE_TTL_MS = 10_000');
+ expect(source).toContain('const [isLoadPending, setIsLoadPending] = useState(false)');
+ expect(source).toContain('setIsLoadPending(true);');
+ expect(source).toContain('return await loadLabelData(apiMessageGet, query);');
+ expect(source).toContain('setIsLoadPending(false);');
+ expect(source).toContain('isLoadPending={isLoadPending}');
+ expect(source).toContain("['setting-labels', labelListUrl, reloadVersion].join(':')");
+ expect(source).toContain('[labelListUrl, reloadVersion]');
+ expect(source).toContain('onRefresh={() => {');
+ expect(source).toContain('if (retry) {');
+ expect(source).toContain('retry();');
+ expect(source).toContain('setReloadVersion(version => version + 1)');
+ expect(source).toContain('cacheKey={labelManageCacheKey}');
+ expect(source).toContain('cacheSettledTtlMs={SETTING_LABELS_SETTLED_CACHE_TTL_MS}');
+ });
+
+ it('resets the search draft and query when the Angular clear event fires', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
+
+ expect(source).toContain("onSearch={() => setQuery({ search, type: '' })}");
+ expect(source).toContain('onSearchClear={() => {');
+ expect(source).toContain("setSearch('');");
+ expect(source).toContain("setQuery({ search: '', type: '' });");
+ });
+
+ it('routes label save feedback through Angular create/edit notification keys', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
+
+ expect(source).toContain('const [actionMessage, setActionMessage] = useState<string | null>(null)');
+ expect(source).toContain('const [actionError, setActionError] = useState<string | null>(null)');
+ expect(source).toContain('const [actionMeta, setActionMeta] = useState<string | null>(null)');
+ expect(source).toContain('const [isNameValidationVisible, setIsNameValidationVisible] = useState(false)');
+ expect(source).toContain('actionMessage={actionMessage}');
+ expect(source).toContain('actionError={actionError}');
+ expect(source).toContain('actionMeta={actionMeta}');
+ expect(source).toContain('isNameValidationVisible={isNameValidationVisible}');
+ expect(source).toContain('if (draftLabel.name == undefined || draftLabel.name.length === 0) {');
+ expect(source).toContain('setIsNameValidationVisible(true);');
+ expect(source).toContain('return;');
+ expect(source).toContain('setIsNameValidationVisible(false);');
+ expect(source).toContain("setActionMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'))");
+ expect(source).toContain("setActionError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))");
+ expect(source).toContain('setActionMeta(error instanceof Error ? error.message : null)');
+ expect(source).not.toContain("setActionMessage(t('common.save-success'))");
+ expect(source).not.toContain("setActionError(t('common.save-failed'))");
+ });
+
+ it('lets a whitespace-only label name follow Angular required-before-trim save behavior', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(<SettingLabelsPage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onNew();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDraftChange({ name: ' ', tagValue: 'ops' });
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSaveDialog();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveLabel).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ expect.objectContaining({ name: ' ', tagValue: 'ops' }),
+ true
+ );
+ expect(mockState.lastSurfaceProps?.isNameValidationVisible).toBe(false);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ }, 30000);
+
+ it('keeps Angular label create failure title with backend detail', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ saveLabel.mockRejectedValue(new Error('backend refused label create'));
+
+ await act(async () => {
+ root.render(<SettingLabelsPage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onNew();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDraftChange({ name: 'team', tagValue: 'ops' });
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSaveDialog();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveLabel).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ expect.objectContaining({ name: 'team', tagValue: 'ops' }),
+ true
+ );
+ expect(mockState.lastSurfaceProps?.actionError).toBe('新增失败!');
+ expect(mockState.lastSurfaceProps?.actionMeta).toBe('backend refused label create');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ }, 30000);
+
+ it('keeps Angular label edit failure title with backend detail', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ saveLabel.mockRejectedValue(new Error('backend refused label edit'));
+
+ await act(async () => {
+ root.render(<SettingLabelsPage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onEdit(mockState.renderData.list.content[0]);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onSaveDialog();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveLabel).toHaveBeenCalledWith(
+ apiMessagePost,
+ apiMessagePut,
+ mockState.renderData.list.content[0],
+ false
+ );
+ expect(mockState.lastSurfaceProps?.actionError).toBe('修改失败!');
+ expect(mockState.lastSurfaceProps?.actionMeta).toBe('backend refused label edit');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ }, 30000);
+
+ it('routes label copy feedback through Angular clipboard notification keys', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
+
+ expect(source).toContain("import { buildLabelDisplayName } from '@/lib/label-manage/view-model'");
+ expect(source).toContain('const text = buildLabelDisplayName(label)');
+ expect(source).toContain("setActionError(t('common.notify.copy-fail'))");
+ expect(source).toContain("then(() => setActionMessage(t('common.notify.copy-success')))");
+ expect(source).toContain("catch(() => setActionError(t('common.notify.copy-fail')))");
+ expect(source).not.toContain('navigator.clipboard.writeText(text);');
+ });
+
+ it('closes the Angular label delete confirmation before the delete result settles', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ deleteLabel.mockReturnValue(new Promise(() => undefined));
+
+ await act(async () => {
+ root.render(<SettingLabelsPage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeleteRequest(mockState.renderData.list.content[0]);
+ await Promise.resolve();
+ });
+
+ expect(mockState.lastSurfaceProps?.deleteTarget?.name).toBe('team');
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeleteConfirm();
+ await Promise.resolve();
+ });
+
+ expect(deleteLabel).toHaveBeenCalledWith(apiMessageDelete, 1);
+ expect(mockState.lastSurfaceProps?.deleteTarget).toBeNull();
+ expect(mockState.lastSurfaceProps?.isDeletePending).toBe(true);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ }, 30000);
+
+ it('keeps Angular label delete failure title with backend detail', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ deleteLabel.mockRejectedValue(new Error('backend refused label delete'));
+
+ await act(async () => {
+ root.render(<SettingLabelsPage />);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeleteRequest(mockState.renderData.list.content[0]);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ mockState.lastSurfaceProps?.onDeleteConfirm();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(deleteLabel).toHaveBeenCalledWith(apiMessageDelete, 1);
+ expect(mockState.lastSurfaceProps?.deleteTarget).toBeNull();
+ expect(mockState.lastSurfaceProps?.actionError).toBe('删除失败!');
+ expect(mockState.lastSurfaceProps?.actionMeta).toBe('backend refused label delete');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ container.remove();
+ }, 30000);
+
+ it('routes label delete through Angular confirmation and notification keys', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
+
+ expect(source).toContain('const [deleteTarget, setDeleteTarget] = useState<Label | null>(null)');
+ expect(source).toContain('const [isDeletePending, setIsDeletePending] = useState(false)');
+ expect(source).toContain('const [isSavePending, setIsSavePending] = useState(false)');
+ expect(source).toContain('deleteTarget={deleteTarget}');
+ expect(source).toContain('isDeletePending={isDeletePending}');
+ expect(source).toContain('isSavePending={isSavePending}');
+ expect(source).toContain('onDeleteRequest={label =>');
+ expect(source).toContain('setDeleteTarget(label)');
+ expect(source).toContain('onDeleteConfirm={() =>');
+ expect(source).toContain('const target = deleteTarget;');
+ expect(source).toContain('setDeleteTarget(null);');
+ expect(source).toContain('void deleteLabel(apiMessageDelete, target.id)');
+ expect(source).toContain("setActionMessage(t('common.notify.delete-success'))");
+ expect(source).toContain("setActionError(t('common.notify.delete-fail'))");
+ expect(source).toContain('setActionMeta(error instanceof Error ? error.message : null)');
+ expect(source).not.toContain('onDelete={label =>');
+ });
+
+ it('routes label save pending state through Angular nzOkLoading semantics', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/labels/setting-labels-page.tsx'), 'utf8');
+
+ expect(source).toContain('const [isSavePending, setIsSavePending] = useState(false)');
+ expect(source).toContain('isSavePending={isSavePending}');
+ expect(source).toContain('setIsSavePending(true);');
+ expect(source).toContain('setIsSavePending(false);');
+ expect(source).toContain('.finally(() => {');
+ expect(source).not.toContain('isManageModalOkLoading');
+ });
+
+ it('keeps Angular trim-before-save normalization in the label controller', () => {
+ const source = readFileSync(resolve(process.cwd(), 'lib/label-manage/controller.ts'), 'utf8');
+
+ expect(source).toContain('function normalizeLabelDraft(draft: Label): Label');
+ expect(source).toContain("name: (draft.name ?? '').trim()");
+ expect(source).toContain('tagValue: draft.tagValue == undefined ? undefined : draft.tagValue.trim()');
+ expect(source).toContain('description: draft.description == undefined ? undefined : draft.description.trim()');
+ expect(source).toContain('const payload = normalizeLabelDraft(draft)');
+ });
});
diff --git a/web-next/app/setting/labels/page.tsx b/web-next/app/setting/labels/page.tsx
index 5a041ce0a7..63839a61ce 100644
--- a/web-next/app/setting/labels/page.tsx
+++ b/web-next/app/setting/labels/page.tsx
@@ -1,70 +1,7 @@
-'use client';
+import React from 'react';
-import { useCallback, useState } from 'react';
-import { ClientWorkbench } from '@/components/workbench/client-workbench';
-import { LabelManageSurface } from '@/components/pages/label-manage-surface';
-import { useI18n } from '@/components/providers/i18n-provider';
-import { apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
-import { formatTime } from '@/lib/format';
-import { cloneLabelDraft, createEmptyLabelDraft, deleteLabel, loadLabelData, saveLabel } from '@/lib/label-manage/controller';
-import type { LabelQueryState } from '@/lib/label-manage/query-state';
-import type { Label } from '@/lib/types';
+import SettingLabelsPage from './setting-labels-page';
-export default function SettingLabelsPage() {
- const { t } = useI18n();
- const [search, setSearch] = useState('');
- const [query, setQuery] = useState<LabelQueryState>({ search: '', type: '' });
- const [draftLabel, setDraftLabel] = useState<Label | null>(null);
- const [isManageModalAdd, setIsManageModalAdd] = useState(false);
- const [reloadVersion, setReloadVersion] = useState(0);
-
- const load = useCallback(async () => {
- return loadLabelData(apiMessageGet, query);
- }, [query, reloadVersion]);
-
- return (
- <ClientWorkbench load={load} loadingCopy={t('setting.labels.loading')}>
- {data => (
- <LabelManageSurface
- t={t}
- data={data}
- search={search}
- draftLabel={draftLabel}
- isManageModalAdd={isManageModalAdd}
- formatTime={formatTime}
- onSearchChange={setSearch}
- onSearch={() => setQuery({ search, type: '' })}
- onRefresh={() => setReloadVersion(version => version + 1)}
- onNew={() => {
- setDraftLabel(createEmptyLabelDraft());
- setIsManageModalAdd(true);
- }}
- onCopy={label => {
- const text = `${label.name}${label.tagValue ? `:${label.tagValue}` : ''}`;
- if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
- void navigator.clipboard.writeText(text);
- }
- }}
- onEdit={label => {
- setDraftLabel(cloneLabelDraft(label));
- setIsManageModalAdd(false);
- }}
- onDelete={label => {
- void deleteLabel(apiMessageDelete, label.id).then(() => {
- setReloadVersion(version => version + 1);
- });
- }}
- onDraftChange={patch => setDraftLabel(previous => (previous ? { ...previous, ...patch } : previous))}
- onCloseDialog={() => setDraftLabel(null)}
- onSaveDialog={() => {
- if (!draftLabel) return;
- void saveLabel(apiMessagePost, apiMessagePut, draftLabel, isManageModalAdd).then(() => {
- setDraftLabel(null);
- setReloadVersion(version => version + 1);
- });
- }}
- />
- )}
- </ClientWorkbench>
- );
+export default function SettingLabelsRoutePage() {
+ return <SettingLabelsPage />;
}
diff --git a/web-next/app/setting/labels/setting-labels-page.tsx b/web-next/app/setting/labels/setting-labels-page.tsx
new file mode 100644
index 0000000000..912928a90e
--- /dev/null
+++ b/web-next/app/setting/labels/setting-labels-page.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import { useCallback, useMemo, useState } from 'react';
+import { ClientWorkbench } from '@/components/workbench/client-workbench';
+import { LabelManageSurface } from '@/components/pages/label-manage-surface';
+import { useI18n } from '@/components/providers/i18n-provider';
+import { apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
+import { formatTime } from '@/lib/format';
+import { createEmptyLabelDraft, deleteLabel, loadLabelData, saveLabel } from '@/lib/label-manage/controller';
+import { buildLabelUrl, type LabelQueryState } from '@/lib/label-manage/query-state';
+import { buildLabelDisplayName } from '@/lib/label-manage/view-model';
+import type { Label } from '@/lib/types';
+
+const SETTING_LABELS_SETTLED_CACHE_TTL_MS = 10_000;
+const EMPTY_LABEL_DATA = {
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 9999
+ }
+};
+
+export default function SettingLabelsPage() {
+ const { t } = useI18n();
+ const [search, setSearch] = useState('');
+ const [query, setQuery] = useState<LabelQueryState>({ search: '', type: '' });
+ const [draftLabel, setDraftLabel] = useState<Label | null>(null);
+ const [isManageModalAdd, setIsManageModalAdd] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<Label | null>(null);
+ const [isLoadPending, setIsLoadPending] = useState(false);
+ const [isDeletePending, setIsDeletePending] = useState(false);
+ const [isSavePending, setIsSavePending] = useState(false);
+ const [isNameValidationVisible, setIsNameValidationVisible] = useState(false);
+ const [actionMessage, setActionMessage] = useState<string | null>(null);
+ const [actionError, setActionError] = useState<string | null>(null);
+ const [actionMeta, setActionMeta] = useState<string | null>(null);
+ const [reloadVersion, setReloadVersion] = useState(0);
+ const labelListUrl = useMemo(() => buildLabelUrl(query), [query]);
+ const labelManageCacheKey = useMemo(
+ () => ['setting-labels', labelListUrl, reloadVersion].join(':'),
+ [labelListUrl, reloadVersion]
+ );
+
+ const load = useCallback(async () => {
+ setIsLoadPending(true);
+ try {
+ return await loadLabelData(apiMessageGet, query);
+ } finally {
+ setIsLoadPending(false);
+ }
+ }, [query]);
+
+ const renderSurface = useCallback(
+ (data: Awaited<ReturnType<typeof loadLabelData>>, loadError: string | null = null, retry?: () => void) => (
+ <LabelManageSurface
+ t={t}
+ data={data}
+ search={search}
+ draftLabel={draftLabel}
+ isManageModalAdd={isManageModalAdd}
+ isNameValidationVisible={isNameValidationVisible}
+ deleteTarget={deleteTarget}
+ isLoadPending={isLoadPending}
+ isDeletePending={isDeletePending}
+ isSavePending={isSavePending}
+ actionMessage={actionMessage}
+ actionError={actionError}
+ actionMeta={actionMeta}
+ loadError={loadError}
+ formatTime={formatTime}
+ onSearchChange={setSearch}
+ onSearch={() => setQuery({ search, type: '' })}
+ onSearchClear={() => {
+ setSearch('');
+ setQuery({ search: '', type: '' });
+ }}
+ onRefresh={() => {
+ if (retry) {
+ retry();
+ return;
+ }
+ setReloadVersion(version => version + 1);
+ }}
+ onNew={() => {
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setIsNameValidationVisible(false);
+ setDeleteTarget(null);
+ setDraftLabel(createEmptyLabelDraft());
+ setIsManageModalAdd(true);
+ }}
+ onCopy={label => {
+ const text = buildLabelDisplayName(label);
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
+ setActionError(t('common.notify.copy-fail'));
+ return;
+ }
+ void navigator.clipboard
+ .writeText(text)
+ .then(() => setActionMessage(t('common.notify.copy-success')))
+ .catch(() => setActionError(t('common.notify.copy-fail')));
+ }}
+ onEdit={label => {
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setIsNameValidationVisible(false);
+ setDeleteTarget(null);
+ setDraftLabel(label);
+ setIsManageModalAdd(false);
+ }}
+ onDeleteRequest={label => {
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setDeleteTarget(label);
+ }}
+ onDeleteCancel={() => {
+ if (!isDeletePending) setDeleteTarget(null);
+ }}
+ onDeleteConfirm={() => {
+ if (!deleteTarget) return;
+ const target = deleteTarget;
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setDeleteTarget(null);
+ setIsDeletePending(true);
+ void deleteLabel(apiMessageDelete, target.id)
+ .then(() => {
+ setActionMessage(t('common.notify.delete-success'));
+ setReloadVersion(version => version + 1);
+ })
+ .catch(error => {
+ setActionError(t('common.notify.delete-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsDeletePending(false);
+ });
+ }}
+ onDraftChange={patch => setDraftLabel(previous => {
+ if (!previous) return previous;
+ if (!isManageModalAdd && previous.id) {
+ const sourceLabel = data.list.content.find(label => label.id === previous.id);
+ if (sourceLabel) Object.assign(sourceLabel, patch);
+ }
+ return { ...previous, ...patch };
+ })}
+ onCloseDialog={() => {
+ setIsNameValidationVisible(false);
+ setDraftLabel(null);
+ }}
+ onSaveDialog={() => {
+ if (!draftLabel) return;
+ if (draftLabel.name == undefined || draftLabel.name.length === 0) {
+ setIsNameValidationVisible(true);
+ return;
+ }
+ const isEdit = !isManageModalAdd && Boolean(draftLabel.id);
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setIsNameValidationVisible(false);
+ setIsSavePending(true);
+ void saveLabel(apiMessagePost, apiMessagePut, draftLabel, isManageModalAdd)
+ .then(() => {
+ setIsNameValidationVisible(false);
+ setDraftLabel(null);
+ setActionMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'));
+ setReloadVersion(version => version + 1);
+ })
+ .catch(error => {
+ setActionError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsSavePending(false);
+ });
+ }}
+ />
+ ),
+ [
+ actionError,
+ actionMessage,
+ actionMeta,
+ deleteTarget,
+ draftLabel,
+ isDeletePending,
+ isLoadPending,
+ isManageModalAdd,
+ isNameValidationVisible,
+ isSavePending,
+ search,
+ t
+ ]
+ );
+
+ return (
+ <ClientWorkbench
+ load={load}
+ loadingCopy={t('setting.labels.loading')}
+ cacheKey={labelManageCacheKey}
+ cacheSettledTtlMs={SETTING_LABELS_SETTLED_CACHE_TTL_MS}
+ renderError={(message, retry) => renderSurface(EMPTY_LABEL_DATA, message, retry)}
+ >
+ {data => renderSurface(data)}
+ </ClientWorkbench>
+ );
+}
diff --git a/web-next/app/setting/plugins/page.test.tsx b/web-next/app/setting/plugins/page.test.tsx
index 0b0efc5612..50cedeec34 100644
--- a/web-next/app/setting/plugins/page.test.tsx
+++ b/web-next/app/setting/plugins/page.test.tsx
@@ -9,6 +9,7 @@ import { createTranslatorMock } from '../../../test/i18n-test-helper';
const mockState = vi.hoisted(() => ({
lastLoad: null as null | (() => Promise<unknown>),
+ renderErrorMessage: null as string | null,
renderData: {
list: {
content: [
@@ -35,11 +36,9 @@ const loadPluginData = vi.hoisted(() => vi.fn());
vi.mock('@/components/providers/i18n-provider', () => ({
useI18n: () => ({
+ locale: 'zh-CN',
t: createTranslatorMock({
- locale: 'zh-CN',
- overrides: {
- 'setting.plugins.loading': '加载插件'
- }
+ locale: 'zh-CN'
})
})
}));
@@ -47,28 +46,161 @@ vi.mock('@/components/providers/i18n-provider', () => ({
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
- load
+ load,
+ renderError
}: {
children: (data: unknown) => React.ReactNode;
load: () => Promise<unknown>;
+ renderError?: (message: string, retry: () => void) => React.ReactNode;
}) => {
mockState.lastLoad = load;
+ if (mockState.renderErrorMessage && renderError) {
+ return <div data-client-workbench="true">{renderError(mockState.renderErrorMessage, vi.fn())}</div>;
+ }
return <div data-client-workbench="true">{children(mockState.renderData)}</div>;
}
}));
vi.mock('@/components/pages/plugin-manage-surface', () => ({
- PluginManageSurface: ({ search, draftPlugin }: any) => (
+ PluginManageSurface: ({ data, search, draftPlugin, isUploadDialogOpen, paramDraft, actionMessage, actionError, actionMeta, actionTone, actionKind, loadError, uploadValidation, isUploadPending, isParamPending, isLoadPending, isTogglePending, optimisticEnableStatus, deleteTarget }: any) => (
<div
data-plugin-manage-surface="otlp-cold-plugin-console"
data-plugin-manage-style-baseline="hertzbeat-cold-matte"
+ data-plugin-enable-feedback-contract="angular-edit-notify"
+ data-plugin-enable-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-enable-failure-contract="angular-edit-fail-notification"
+ data-plugin-enable-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-enable-optimistic-contract="angular-toggle-mutates-row-before-put"
+ data-plugin-enable-optimistic-owner="route-state-contract"
+ data-plugin-table-loading-contract="angular-load-plugins-table"
+ data-plugin-table-loading-scope="load-search-refresh-pagination-mutation"
+ data-plugin-load-failure-contract="angular-console-only-shell"
+ data-plugin-load-failure-owner="plugin-route-controller"
+ data-plugin-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-plugin-toggle-loading-contract="angular-nz-table-loading"
+ data-plugin-toggle-loading-contract-owner="angular-nz-table-loading"
+ data-plugin-selection-reset-contract="angular-load-clears-selection"
+ data-plugin-selection-reset-owner="route-state-contract"
+ data-plugin-query-param-order-contract="angular-page-index-size-search"
+ data-plugin-query-param-order-owner="plugin-query-state"
+ data-plugin-search-submit-contract="angular-enter-and-clear"
+ data-plugin-search-clear-contract="angular-cleared-load"
+ data-plugin-search-clear-owner="shared-search-row"
+ data-plugin-delete-warning-contract="angular-no-select-warning"
+ data-plugin-delete-confirm-contract="angular-modal-confirm"
+ data-plugin-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-plugin-delete-feedback-contract="angular-delete-notify"
+ data-plugin-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-delete-failure-contract="angular-delete-fail-notification"
+ data-plugin-delete-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-delete-query-contract="angular-repeated-ids-query"
+ data-plugin-delete-query-owner="route-mutation-contract"
+ data-plugin-delete-page-clamp-contract="angular-update-page-index"
+ data-plugin-table-columns-contract="angular-five-column-edit-actions"
+ data-plugin-table-columns-owner="angular-nz-table"
+ data-plugin-table-stable-height-contract="viewport-fill-on-empty-or-short"
+ data-plugin-table-stable-height-owner="route-layout-contract"
+ data-plugin-toolbar-delete-menu-contract="angular-toolbar-ellipsis-delete"
+ data-plugin-toolbar-delete-menu-owner="hertzbeat-ui-table-row-action-button"
+ data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"
+ data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"
+ data-plugin-upload-validation-contract="angular-required-before-submit"
+ data-plugin-upload-invalid-submit-contract="angular-mark-dirty-keep-open"
+ data-plugin-upload-invalid-submit-owner="route-validation-contract"
+ data-plugin-upload-loading-contract="angular-nz-ok-loading"
+ data-plugin-upload-payload-contract="angular-form-data"
+ data-plugin-upload-name-payload-contract="angular-raw-name-no-trim"
+ data-plugin-upload-name-payload-owner="plugin-manage-controller"
+ data-plugin-upload-save-lifecycle-contract="angular-close-success-keep-open-fail"
+ data-plugin-upload-save-lifecycle-owner="route-state-contract"
+ data-plugin-upload-success-reset-contract="angular-reset-form-after-success"
+ data-plugin-upload-success-reset-owner="route-state-contract"
+ data-plugin-upload-cancel-contract="angular-cancel-preserves-form"
+ data-plugin-upload-cancel-contract-owner="route-state-contract"
+ data-plugin-upload-cancel-pending-contract="angular-cancel-allowed-during-ok-loading"
+ data-plugin-upload-cancel-pending-owner="angular-nz-modal"
+ data-plugin-upload-mask-closable-contract="angular-mask-closable-false"
+ data-plugin-upload-width-contract="angular-width-30-percent"
+ data-plugin-upload-modal-owner="angular-nz-modal"
+ data-plugin-upload-field-layout-contract="angular-label-8-control-14"
+ data-plugin-upload-field-layout-owner="route-form-field-grid"
+ data-plugin-upload-status-control-contract="angular-nz-switch"
+ data-plugin-upload-status-control-owner="hertzbeat-ui-switch"
+ data-plugin-upload-file-list-contract="angular-before-upload-single-replace"
+ data-plugin-upload-file-list-owner="hertzbeat-ui-file-input"
+ data-plugin-upload-file-remove-contract="angular-nz-remove-clears-jar"
+ data-plugin-upload-file-remove-owner="hertzbeat-ui-file-input"
+ data-plugin-upload-feedback-contract="angular-new-notify"
+ data-plugin-upload-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-upload-failure-contract="angular-new-fail-notification"
+ data-plugin-upload-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-param-edit-contract="angular-params-define-modal"
+ data-plugin-param-action-visibility-contract="angular-paramcount-zero-only"
+ data-plugin-param-action-visibility-owner="plugin-view-model"
+ data-plugin-param-mask-closable-contract="angular-mask-closable-false"
+ data-plugin-param-modal-owner="angular-nz-modal"
+ data-plugin-param-save-contract="angular-params-post"
+ data-plugin-param-payload-contract="angular-object-values-payload"
+ data-plugin-param-save-lifecycle-contract="angular-close-success-keep-open-fail"
+ data-plugin-param-save-lifecycle-owner="route-state-contract"
+ data-plugin-param-save-loading-contract="angular-no-ok-loading"
+ data-plugin-param-save-loading-owner="angular-modal-ok-contract"
+ data-plugin-param-feedback-contract="angular-edit-notify"
+ data-plugin-param-failure-contract="angular-edit-fail-notification"
+ data-plugin-param-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-param-empty-contract="angular-empty-params-modal"
+ data-plugin-param-empty-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-plugin-param-pending-editable-contract="angular-controls-remain-enabled"
+ data-plugin-param-pending-editable-owner="angular-param-modal"
+ data-plugin-param-width-contract="angular-default-modal-width"
+ data-plugin-param-width-owner="angular-nz-modal"
+ data-plugin-param-radio-contract="angular-nz-radio-group"
+ data-plugin-param-radio-owner="hertzbeat-ui-radio-button-group"
+ data-plugin-param-textarea-contract="angular-textarea-rows-8"
+ data-plugin-param-textarea-owner="hertzbeat-ui-textarea"
+ data-plugin-param-advanced-fields-contract="angular-configurable-and-multi-func"
+ data-plugin-param-advanced-fields-owner="hertzbeat-ui-advanced-param-controls"
+ data-plugin-param-configurable-field-contract="angular-app-configurable-field"
+ data-plugin-param-configurable-field-owner="hertzbeat-ui-configurable-field"
+ data-plugin-param-key-value-contract="angular-app-configurable-field"
+ data-plugin-param-key-value-owner="hertzbeat-ui-key-value-editor"
+ data-plugin-param-labels-contract="angular-app-configurable-field"
+ data-plugin-param-labels-owner="hertzbeat-ui-key-value-editor"
+ data-plugin-param-label-selector-contract="angular-app-label-selector"
+ data-plugin-param-label-selector-owner="cold-label-selector"
+ data-plugin-param-metrics-field-contract="angular-app-configurable-field"
+ data-plugin-param-metrics-field-owner="hertzbeat-ui-configurable-field-editor"
+ data-plugin-param-array-contract="angular-app-multi-func-input"
+ data-plugin-param-array-owner="hertzbeat-ui-input"
+ data-plugin-param-field-layout-contract="angular-label-7-control-8"
+ data-plugin-param-field-layout-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-plugin-param-form-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-plugin-pagination="cold-dense-pagination"
+ data-plugin-pagination-owner="hertzbeat-ui-pagination-bar"
+ data-plugin-pagination-contract="angular-search-pagination"
>
<section data-plugin-admin-layout="full-width-admin-list">
<span>插件表格</span>
</section>
+ <span data-plugin-test-row-count="true">{data.list.content.length}</span>
<span>插件管理</span>
<span>{search}</span>
<span>{draftPlugin ? draftPlugin.name : 'no-draft'}</span>
+ <span>{isUploadDialogOpen ? 'upload-dialog-open' : 'upload-dialog-closed'}</span>
+ <span>{paramDraft ? paramDraft.plugin.name : 'no-param-draft'}</span>
+ <span>{actionMessage ?? 'no-action-message'}</span>
+ <span>{actionError ?? 'no-action-error'}</span>
+ <span>{actionMeta ?? 'no-action-meta'}</span>
+ <span>{actionTone ?? 'no-action-tone'}</span>
+ <span>{actionKind ?? 'no-action-kind'}</span>
+ <span data-plugin-test-load-error={loadError ? 'present' : 'none'} />
+ <span>{uploadValidation?.name ? 'name-invalid' : 'name-valid'}</span>
+ <span>{isUploadPending ? 'upload-pending' : 'upload-idle'}</span>
+ <span>{isParamPending ? 'param-pending' : 'param-idle'}</span>
+ <span>{isLoadPending ? 'load-pending' : 'load-idle'}</span>
+ <span>{isTogglePending ? 'toggle-pending' : 'toggle-idle'}</span>
+ <span>{Object.keys(optimisticEnableStatus ?? {}).length === 0 ? 'no-optimistic-enable-status' : 'optimistic-enable-status'}</span>
+ <span>{deleteTarget ? deleteTarget.label : 'no-delete-target'}</span>
</div>
)
}));
@@ -82,15 +214,30 @@ vi.mock('@/lib/api-client', () => ({
vi.mock('@/lib/plugin-manage/controller', () => ({
loadPluginData,
- createEmptyPluginDraft: () => ({ name: '', jarFileName: '', enableStatus: true }),
+ createEmptyPluginDraft: () => ({ name: '', jarFileName: '', jarFile: null, enableStatus: true }),
savePlugin: vi.fn(),
+ loadPluginParamDraft: vi.fn(),
+ updatePluginParamDraft: vi.fn((draft, field, value) => ({
+ ...draft,
+ params: {
+ ...draft.params,
+ [field]: {
+ ...draft.params[field],
+ paramValue: value
+ }
+ }
+ })),
+ savePluginParams: vi.fn(),
togglePluginStatus: vi.fn(),
- deletePlugin: vi.fn()
+ validatePluginUploadDraft: vi.fn(() => ({ name: true, jarFile: true })),
+ deletePlugins: vi.fn(),
+ clampPluginPageIndexAfterDelete: vi.fn(() => 0)
}));
describe('setting plugins page', () => {
beforeEach(() => {
mockState.lastLoad = null;
+ mockState.renderErrorMessage = null;
apiMessageGet.mockReset();
apiMessagePost.mockReset();
apiMessagePut.mockReset();
@@ -108,25 +255,264 @@ describe('setting plugins page', () => {
expect(html).toContain('data-client-workbench="true"');
expect(html).toContain('data-plugin-manage-surface="otlp-cold-plugin-console"');
expect(html).toContain('data-plugin-manage-style-baseline="hertzbeat-cold-matte"');
+ expect(html).toContain('data-plugin-enable-feedback-contract="angular-edit-notify"');
+ expect(html).toContain('data-plugin-enable-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-enable-failure-contract="angular-edit-fail-notification"');
+ expect(html).toContain('data-plugin-enable-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-enable-optimistic-contract="angular-toggle-mutates-row-before-put"');
+ expect(html).toContain('data-plugin-enable-optimistic-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-table-loading-contract="angular-load-plugins-table"');
+ expect(html).toContain('data-plugin-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(html).toContain('data-plugin-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-plugin-load-failure-owner="plugin-route-controller"');
+ expect(html).toContain('data-plugin-load-failure="none"');
+ expect(html).toContain('data-plugin-toggle-loading-contract="angular-nz-table-loading"');
+ expect(html).toContain('data-plugin-toggle-loading-contract-owner="angular-nz-table-loading"');
+ expect(html).toContain('data-plugin-selection-reset-contract="angular-load-clears-selection"');
+ expect(html).toContain('data-plugin-selection-reset-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-search-submit-contract="angular-enter-and-clear"');
+ expect(html).toContain('data-plugin-search-clear-contract="angular-cleared-load"');
+ expect(html).toContain('data-plugin-search-clear-owner="shared-search-row"');
+ expect(html).toContain('data-plugin-query-param-order-contract="angular-page-index-size-search"');
+ expect(html).toContain('data-plugin-query-param-order-owner="plugin-query-state"');
+ expect(html).toContain('data-plugin-delete-warning-contract="angular-no-select-warning"');
+ expect(html).toContain('data-plugin-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-plugin-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-plugin-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-plugin-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-delete-failure-contract="angular-delete-fail-notification"');
+ expect(html).toContain('data-plugin-delete-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-delete-query-contract="angular-repeated-ids-query"');
+ expect(html).toContain('data-plugin-delete-query-owner="route-mutation-contract"');
+ expect(html).toContain('data-plugin-delete-page-clamp-contract="angular-update-page-index"');
+ expect(html).toContain('data-plugin-table-columns-contract="angular-five-column-edit-actions"');
+ expect(html).toContain('data-plugin-table-columns-owner="angular-nz-table"');
+ expect(html).toContain('data-plugin-table-stable-height-contract="viewport-fill-on-empty-or-short"');
+ expect(html).toContain('data-plugin-table-stable-height-owner="route-layout-contract"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-contract="angular-toolbar-ellipsis-delete"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(html).toContain('data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-plugin-upload-validation-contract="angular-required-before-submit"');
+ expect(html).toContain('data-plugin-upload-invalid-submit-contract="angular-mark-dirty-keep-open"');
+ expect(html).toContain('data-plugin-upload-invalid-submit-owner="route-validation-contract"');
+ expect(html).toContain('data-plugin-upload-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-plugin-upload-payload-contract="angular-form-data"');
+ expect(html).toContain('data-plugin-upload-name-payload-contract="angular-raw-name-no-trim"');
+ expect(html).toContain('data-plugin-upload-name-payload-owner="plugin-manage-controller"');
+ expect(html).toContain('data-plugin-upload-save-lifecycle-contract="angular-close-success-keep-open-fail"');
+ expect(html).toContain('data-plugin-upload-save-lifecycle-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-success-reset-contract="angular-reset-form-after-success"');
+ expect(html).toContain('data-plugin-upload-success-reset-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-cancel-contract="angular-cancel-preserves-form"');
+ expect(html).toContain('data-plugin-upload-cancel-contract-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-cancel-pending-contract="angular-cancel-allowed-during-ok-loading"');
+ expect(html).toContain('data-plugin-upload-cancel-pending-owner="angular-nz-modal"');
+ expect(html).toContain('data-plugin-upload-mask-closable-contract="angular-mask-closable-false"');
+ expect(html).toContain('data-plugin-upload-width-contract="angular-width-30-percent"');
+ expect(html).toContain('data-plugin-upload-modal-owner="angular-nz-modal"');
+ expect(html).toContain('data-plugin-upload-field-layout-contract="angular-label-8-control-14"');
+ expect(html).toContain('data-plugin-upload-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-plugin-upload-status-control-contract="angular-nz-switch"');
+ expect(html).toContain('data-plugin-upload-status-control-owner="hertzbeat-ui-switch"');
+ expect(html).toContain('data-plugin-upload-file-list-contract="angular-before-upload-single-replace"');
+ expect(html).toContain('data-plugin-upload-file-list-owner="hertzbeat-ui-file-input"');
+ expect(html).toContain('data-plugin-upload-file-remove-contract="angular-nz-remove-clears-jar"');
+ expect(html).toContain('data-plugin-upload-file-remove-owner="hertzbeat-ui-file-input"');
+ expect(html).toContain('data-plugin-upload-feedback-contract="angular-new-notify"');
+ expect(html).toContain('data-plugin-upload-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-upload-failure-contract="angular-new-fail-notification"');
+ expect(html).toContain('data-plugin-upload-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-param-edit-contract="angular-params-define-modal"');
+ expect(html).toContain('data-plugin-param-action-visibility-contract="angular-paramcount-zero-only"');
+ expect(html).toContain('data-plugin-param-action-visibility-owner="plugin-view-model"');
+ expect(html).toContain('data-plugin-param-mask-closable-contract="angular-mask-closable-false"');
+ expect(html).toContain('data-plugin-param-modal-owner="angular-nz-modal"');
+ expect(html).toContain('data-plugin-param-save-contract="angular-params-post"');
+ expect(html).toContain('data-plugin-param-payload-contract="angular-object-values-payload"');
+ expect(html).toContain('data-plugin-param-save-lifecycle-contract="angular-close-success-keep-open-fail"');
+ expect(html).toContain('data-plugin-param-save-lifecycle-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-param-save-loading-contract="angular-no-ok-loading"');
+ expect(html).toContain('data-plugin-param-save-loading-owner="angular-modal-ok-contract"');
+ expect(html).toContain('data-plugin-param-feedback-contract="angular-edit-notify"');
+ expect(html).toContain('data-plugin-param-failure-contract="angular-edit-fail-notification"');
+ expect(html).toContain('data-plugin-param-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-param-empty-contract="angular-empty-params-modal"');
+ expect(html).toContain('data-plugin-param-empty-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-pending-editable-contract="angular-controls-remain-enabled"');
+ expect(html).toContain('data-plugin-param-pending-editable-owner="angular-param-modal"');
+ expect(html).toContain('data-plugin-param-width-contract="angular-default-modal-width"');
+ expect(html).toContain('data-plugin-param-width-owner="angular-nz-modal"');
+ expect(html).toContain('data-plugin-param-radio-contract="angular-nz-radio-group"');
+ expect(html).toContain('data-plugin-param-radio-owner="hertzbeat-ui-radio-button-group"');
+ expect(html).toContain('data-plugin-param-textarea-contract="angular-textarea-rows-8"');
+ expect(html).toContain('data-plugin-param-textarea-owner="hertzbeat-ui-textarea"');
+ expect(html).toContain('data-plugin-param-advanced-fields-contract="angular-configurable-and-multi-func"');
+ expect(html).toContain('data-plugin-param-advanced-fields-owner="hertzbeat-ui-advanced-param-controls"');
+ expect(html).toContain('data-plugin-param-configurable-field-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-configurable-field-owner="hertzbeat-ui-configurable-field"');
+ expect(html).toContain('data-plugin-param-key-value-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-key-value-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-plugin-param-labels-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-labels-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-plugin-param-label-selector-contract="angular-app-label-selector"');
+ expect(html).toContain('data-plugin-param-label-selector-owner="cold-label-selector"');
+ expect(html).toContain('data-plugin-param-metrics-field-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-metrics-field-owner="hertzbeat-ui-configurable-field-editor"');
+ expect(html).toContain('data-plugin-param-array-contract="angular-app-multi-func-input"');
+ expect(html).toContain('data-plugin-param-array-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-plugin-param-field-layout-contract="angular-label-7-control-8"');
+ expect(html).toContain('data-plugin-param-field-layout-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-form-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-pagination="cold-dense-pagination"');
+ expect(html).toContain('data-plugin-pagination-owner="hertzbeat-ui-pagination-bar"');
+ expect(html).toContain('data-plugin-pagination-contract="angular-search-pagination"');
expect(html).toContain('data-plugin-admin-layout="full-width-admin-list"');
expect(html).not.toContain('data-plugin-summary-rail=');
expect(html).not.toContain('插件摘要');
expect(html).not.toContain('当前插件');
expect(html).toContain('插件管理');
expect(html).toContain('no-draft');
+ expect(html).toContain('upload-dialog-closed');
+ expect(html).toContain('no-param-draft');
+ expect(html).toContain('no-action-message');
+ expect(html).toContain('no-action-error');
+ expect(html).toContain('no-action-meta');
+ expect(html).toContain('success');
+ expect(html).toContain('enable');
+ expect(html).toContain('name-valid');
+ expect(html).toContain('upload-idle');
+ expect(html).toContain('param-idle');
+ expect(html).toContain('load-idle');
+ expect(html).toContain('toggle-idle');
+ expect(html).toContain('no-optimistic-enable-status');
+ expect(html).toContain('no-delete-target');
await mockState.lastLoad?.();
expect(apiMessageGet).toHaveBeenCalledWith('/plugin?pageIndex=0&pageSize=8');
});
+ it('keeps the Angular plugin table shell visible when plugin loading fails', async () => {
+ mockState.renderErrorMessage = 'backend refused plugin load';
+ const { default: SettingPluginsPage } = await import('./page');
+ const html = renderToStaticMarkup(<SettingPluginsPage />);
+
+ expect(html).toContain('data-client-workbench="true"');
+ expect(html).toContain('data-plugin-manage-surface="otlp-cold-plugin-console"');
+ expect(html).toContain('data-plugin-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-plugin-load-failure-owner="plugin-route-controller"');
+ expect(html).toContain('data-plugin-load-failure="angular-console-only-shell"');
+ expect(html).toContain('data-plugin-test-row-count="true">0');
+ expect(html).toContain('插件表格');
+ expect(html).not.toContain('backend refused plugin load');
+ expect(html).not.toContain('common.load-failed');
+ expect(html).not.toContain('data-observability-status');
+ });
+
it('keeps route composition out of the old observability/workbench visual owner', () => {
- const source = readFileSync(resolve(process.cwd(), 'app/setting/plugins/page.tsx'), 'utf8');
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/plugins/setting-plugins-page.tsx'), 'utf8');
expect(source).toContain('PluginManageSurface');
+ expect(source).toContain('const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false)');
+ expect(source).toContain('const [actionMessage, setActionMessage] = useState<string | null>(null)');
+ expect(source).toContain('const [actionError, setActionError] = useState<string | null>(null)');
+ expect(source).toContain("const [actionTone, setActionTone] = useState<'success' | 'warning' | 'critical'>('success')");
+ expect(source).toContain("const [paramDraft, setParamDraft] = useState<PluginParamDraft | null>(null)");
+ expect(source).toContain("const [actionKind, setActionKind] = useState<'enable' | 'delete' | 'upload' | 'params'>('enable')");
+ expect(source).toContain('const [optimisticEnableStatus, setOptimisticEnableStatus] = useState<Record<number, boolean>>({})');
+ expect(source).toContain('const [uploadValidation, setUploadValidation] = useState<{ name?: boolean; jarFile?: boolean }>({})');
+ expect(source).toContain('const [isUploadPending, setIsUploadPending] = useState(false)');
+ expect(source).toContain('const [isParamPending, setIsParamPending] = useState(false)');
+ expect(source).toContain('const [deleteTarget, setDeleteTarget] = useState<PluginDeleteTarget | null>(null)');
+ expect(source).toContain('const [isLoadPending, setIsLoadPending] = useState(false)');
+ expect(source).toContain('buildFallbackPluginData');
+ expect(source).toContain('renderError={(message, retry) => renderPluginSurface(buildFallbackPluginData(), message, retry)}');
+ expect(source).toContain('loadError={loadError}');
+ expect(source).toContain('const [isDeletePending, setIsDeletePending] = useState(false)');
+ expect(source).toContain('const [isTogglePending, setIsTogglePending] = useState(false)');
+ expect(source).toContain("setActionMessage(t('common.notify.edit-success'))");
+ expect(source).toContain("setActionError(t('common.notify.edit-fail'))");
+ expect(source).toContain('setActionMeta(error instanceof Error ? error.message : null)');
+ expect(source).toContain("setActionError(t('common.notify.no-select-delete'))");
+ expect(source).toContain("setActionMessage(t('common.notify.delete-success'))");
+ expect(source).toContain("setActionError(t('common.notify.delete-fail'))");
+ expect(source).toContain('validatePluginUploadDraft(draftPlugin)');
+ expect(source).toContain('if (!validation.name || !validation.jarFile) {');
+ expect(source).toContain('setUploadValidation({');
+ expect(source).toContain('isUploadDialogOpen={isUploadDialogOpen}');
+ expect(source).toContain('setIsUploadDialogOpen(true)');
+ expect(source).toContain('setIsUploadDialogOpen(false)');
+ expect(source).toContain('onCloseDialog={() => {');
+ expect(source).toContain('setIsUploadDialogOpen(false);');
+ expect(source).not.toContain('if (!isUploadPending) {');
+ expect(source).toContain('if (!draftPlugin) {');
+ expect(source).toContain("setActionKind('upload')");
+ expect(source).toContain("setActionMessage(t('common.notify.new-success'))");
+ expect(source).toContain("setActionError(t('common.notify.new-fail'))");
+ expect(source).toContain('setUploadValidation({});');
+ expect(source).toContain('setDraftPlugin(null);');
+ expect(source).toContain('void deletePlugins(apiMessageDelete, targetIds)');
+ expect(source).toContain('void savePlugin(apiMessagePost, draftPlugin)');
+ expect(source).toContain('void loadPluginParamDraft(apiMessageGet, plugin, locale)');
+ expect(source).toContain("setActionKind('params')");
+ expect(source).toContain('updatePluginParamDraft(previous, field, value)');
+ expect(source).toContain('void savePluginParams(apiMessagePost, paramDraft.params)');
+ expect(source).toContain("setActionMessage(t('common.notify.edit-success'))");
+ expect(source).toContain('setParamDraft(null);');
+ expect(source).toContain("setActionError(t('common.notify.edit-fail'))");
+ expect(source).toContain('onCloseParamDialog={() => {');
+ expect(source).toContain('setParamDraft(null);');
+ expect(source).not.toContain('if (!isParamPending) setParamDraft(null);');
+ expect(source).toContain('isParamPending={isParamPending}');
+ expect(source).toContain('clampPluginPageIndexAfterDelete({');
+ expect(source).toContain('onPageIndexChange={pageIndex => {');
+ expect(source).toContain('onPageSizeChange={pageSize => {');
+ expect(source.match(/setSelectedIds\(\[\]\)/g)?.length).toBeGreaterThanOrEqual(6);
+ expect(source).toContain('onSearch={() => {');
+ expect(source).toContain('onSearchClear={() => {');
+ expect(source).toContain("setQuery({ search: '', pageIndex: 0, pageSize: query.pageSize });");
+ expect(source).toContain('onRefresh={() => {');
+ expect(source).toContain('setQuery(current => ({ ...current, pageIndex }))');
+ expect(source).toContain('setQuery(current => ({ ...current, pageIndex: 0, pageSize }))');
+ expect(source).toContain('actionMessage={actionMessage}');
+ expect(source).toContain('actionError={actionError}');
+ expect(source).toContain('actionMeta={actionMeta}');
+ expect(source).toContain('actionTone={actionTone}');
+ expect(source).toContain('actionKind={actionKind}');
+ expect(source).toContain('uploadValidation={uploadValidation}');
+ expect(source).toContain('isUploadPending={isUploadPending}');
+ expect(source).toContain('paramDraft={paramDraft}');
+ expect(source).toContain('isParamPending={isParamPending}');
+ expect(source).toContain('deleteTarget={deleteTarget}');
+ expect(source).toContain('isLoadPending={isLoadPending}');
+ expect(source).toContain('isDeletePending={isDeletePending}');
+ expect(source).toContain('isTogglePending={isTogglePending}');
+ expect(source).toContain('optimisticEnableStatus={optimisticEnableStatus}');
+ expect(source).toContain('setIsLoadPending(true)');
+ expect(source).toContain('setOptimisticEnableStatus({});');
+ expect(source).toContain('setIsLoadPending(false)');
+ expect(source).toContain('setIsTogglePending(true)');
+ expect(source).toContain('setOptimisticEnableStatus(current => ({');
+ expect(source).toContain('[plugin.id]: !Boolean(plugin.enableStatus)');
+ expect(source).toContain('setIsTogglePending(false)');
expect(source).not.toContain('WorkbenchPage');
expect(source).not.toContain('SurfaceSection');
expect(source).not.toContain('StatusState');
expect(source).not.toContain('buildPluginFacts');
});
+
+ it('keeps plugin management remounts on a short settled cache window while reloads invalidate it', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/setting/plugins/setting-plugins-page.tsx'), 'utf8');
+
+ expect(source).toContain('SETTING_PLUGINS_SETTLED_CACHE_TTL_MS = 10_000');
+ expect(source).toContain("['setting-plugins', pluginListUrl, reloadVersion].join(':')");
+ expect(source).toContain('[pluginListUrl, reloadVersion]');
+ expect(source).toContain('onRefresh={() => {');
+ expect(source).toContain('setActionMessage(null);');
+ expect(source).toContain('setActionError(null);');
+ expect(source).toContain('setReloadVersion(version => version + 1)');
+ expect(source).toContain('cacheKey={pluginManageCacheKey}');
+ expect(source).toContain('cacheSettledTtlMs={SETTING_PLUGINS_SETTLED_CACHE_TTL_MS}');
+ });
});
diff --git a/web-next/app/setting/plugins/page.tsx b/web-next/app/setting/plugins/page.tsx
index 4ea0b59a07..4cce6d5647 100644
--- a/web-next/app/setting/plugins/page.tsx
+++ b/web-next/app/setting/plugins/page.tsx
@@ -1,69 +1,7 @@
-'use client';
+import React from 'react';
-import { useCallback, useState } from 'react';
-import { ClientWorkbench } from '@/components/workbench/client-workbench';
-import { PluginManageSurface } from '@/components/pages/plugin-manage-surface';
-import { useI18n } from '@/components/providers/i18n-provider';
-import { apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
-import { createEmptyPluginDraft, deletePlugin, loadPluginData, savePlugin, togglePluginStatus, type PluginUploadDraft } from '@/lib/plugin-manage/controller';
-import type { PluginQueryState } from '@/lib/plugin-manage/query-state';
-import type { Plugin } from '@/lib/types';
+import SettingPluginsPage from './setting-plugins-page';
-export default function SettingPluginsPage() {
- const { t } = useI18n();
- const [search, setSearch] = useState('');
- const [query, setQuery] = useState<PluginQueryState>({ search: '' });
- const [reloadVersion, setReloadVersion] = useState(0);
- const [selectedIds, setSelectedIds] = useState<number[]>([]);
- const [draftPlugin, setDraftPlugin] = useState<PluginUploadDraft | null>(null);
-
- const load = useCallback(async () => {
- return loadPluginData(apiMessageGet, query);
- }, [query, reloadVersion]);
-
- return (
- <ClientWorkbench load={load} loadingCopy={t('setting.plugins.loading')}>
- {data => (
- <PluginManageSurface
- t={t}
- data={data}
- search={search}
- selectedIds={selectedIds}
- draftPlugin={draftPlugin}
- onSearchChange={setSearch}
- onSearch={() => setQuery({ search })}
- onRefresh={() => setReloadVersion(version => version + 1)}
- onNew={() => setDraftPlugin(createEmptyPluginDraft())}
- onDeleteSelected={() => {
- selectedIds.forEach(id => {
- void deletePlugin(apiMessageDelete, id);
- });
- setSelectedIds([]);
- setReloadVersion(version => version + 1);
- }}
- onSelectedIdsChange={setSelectedIds}
- onEditParams={(_plugin: Plugin) => {}}
- onToggleEnabled={plugin => {
- void togglePluginStatus(apiMessagePut, plugin).then(() => {
- setReloadVersion(version => version + 1);
- });
- }}
- onDeleteOne={plugin => {
- void deletePlugin(apiMessageDelete, plugin.id).then(() => {
- setReloadVersion(version => version + 1);
- });
- }}
- onDraftChange={patch => setDraftPlugin(previous => (previous ? { ...previous, ...patch } : previous))}
- onCloseDialog={() => setDraftPlugin(null)}
- onSaveDialog={() => {
- if (!draftPlugin) return;
- void savePlugin(apiMessagePost, draftPlugin).then(() => {
- setDraftPlugin(null);
- setReloadVersion(version => version + 1);
- });
- }}
- />
- )}
- </ClientWorkbench>
- );
+export default function SettingPluginsRoutePage() {
+ return <SettingPluginsPage />;
}
diff --git a/web-next/app/setting/plugins/setting-plugins-page.tsx b/web-next/app/setting/plugins/setting-plugins-page.tsx
new file mode 100644
index 0000000000..eb16afa8b2
--- /dev/null
+++ b/web-next/app/setting/plugins/setting-plugins-page.tsx
@@ -0,0 +1,325 @@
+'use client';
+
+import { useCallback, useMemo, useState } from 'react';
+import { ClientWorkbench } from '@/components/workbench/client-workbench';
+import { PluginManageSurface, type PluginDeleteTarget } from '@/components/pages/plugin-manage-surface';
+import { useI18n } from '@/components/providers/i18n-provider';
+import { apiMessageDelete, apiMessageGet, apiMessagePost, apiMessagePut } from '@/lib/api-client';
+import { clampPluginPageIndexAfterDelete, createEmptyPluginDraft, deletePlugins, loadPluginData, loadPluginParamDraft, savePlugin, savePluginParams, togglePluginStatus, updatePluginParamDraft, validatePluginUploadDraft, type PluginManagePageData, type PluginParamDraft, type PluginUploadDraft } from '@/lib/plugin-manage/controller';
+import { buildPluginUrl, type PluginQueryState } from '@/lib/plugin-manage/query-state';
+import type { Plugin } from '@/lib/types';
+
+const SETTING_PLUGINS_SETTLED_CACHE_TTL_MS = 10_000;
+
+export default function SettingPluginsPage() {
+ const { t, locale } = useI18n();
+ const [search, setSearch] = useState('');
+ const [query, setQuery] = useState<PluginQueryState>({ search: '' });
+ const [reloadVersion, setReloadVersion] = useState(0);
+ const [selectedIds, setSelectedIds] = useState<number[]>([]);
+ const [draftPlugin, setDraftPlugin] = useState<PluginUploadDraft | null>(null);
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
+ const [paramDraft, setParamDraft] = useState<PluginParamDraft | null>(null);
+ const [actionMessage, setActionMessage] = useState<string | null>(null);
+ const [actionError, setActionError] = useState<string | null>(null);
+ const [actionMeta, setActionMeta] = useState<string | null>(null);
+ const [actionTone, setActionTone] = useState<'success' | 'warning' | 'critical'>('success');
+ const [actionKind, setActionKind] = useState<'enable' | 'delete' | 'upload' | 'params'>('enable');
+ const [uploadValidation, setUploadValidation] = useState<{ name?: boolean; jarFile?: boolean }>({});
+ const [isUploadPending, setIsUploadPending] = useState(false);
+ const [isParamPending, setIsParamPending] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<PluginDeleteTarget | null>(null);
+ const [isLoadPending, setIsLoadPending] = useState(false);
+ const [isDeletePending, setIsDeletePending] = useState(false);
+ const [isTogglePending, setIsTogglePending] = useState(false);
+ const [optimisticEnableStatus, setOptimisticEnableStatus] = useState<Record<number, boolean>>({});
+ const pluginListUrl = useMemo(() => buildPluginUrl(query), [query]);
+ const pluginManageCacheKey = useMemo(
+ () => ['setting-plugins', pluginListUrl, reloadVersion].join(':'),
+ [pluginListUrl, reloadVersion]
+ );
+
+ const load = useCallback(async () => {
+ setIsLoadPending(true);
+ try {
+ return await loadPluginData(apiMessageGet, query);
+ } finally {
+ setOptimisticEnableStatus({});
+ setIsLoadPending(false);
+ }
+ }, [query]);
+
+ const clearActionFeedback = useCallback(() => {
+ setActionMessage(null);
+ setActionError(null);
+ setActionMeta(null);
+ setActionTone('success');
+ }, []);
+
+ const buildFallbackPluginData = useCallback((): PluginManagePageData => ({
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: query.pageIndex ?? 0,
+ pageSize: query.pageSize ?? 8
+ }
+ }), [query.pageIndex, query.pageSize]);
+
+ function renderPluginSurface(data: PluginManagePageData, loadError: string | null = null, retryLoad?: () => void) {
+ return (
+ <PluginManageSurface
+ t={t}
+ data={data}
+ search={search}
+ selectedIds={selectedIds}
+ draftPlugin={draftPlugin}
+ isUploadDialogOpen={isUploadDialogOpen}
+ paramDraft={paramDraft}
+ actionMessage={actionMessage}
+ actionError={actionError}
+ actionMeta={actionMeta}
+ actionTone={actionTone}
+ actionKind={actionKind}
+ loadError={loadError}
+ uploadValidation={uploadValidation}
+ isUploadPending={isUploadPending}
+ isParamPending={isParamPending}
+ deleteTarget={deleteTarget}
+ isLoadPending={isLoadPending}
+ isDeletePending={isDeletePending}
+ isTogglePending={isTogglePending}
+ optimisticEnableStatus={optimisticEnableStatus}
+ onSearchChange={setSearch}
+ onSearch={() => {
+ clearActionFeedback();
+ setSelectedIds([]);
+ setQuery({ search, pageIndex: 0, pageSize: query.pageSize });
+ }}
+ onSearchClear={() => {
+ clearActionFeedback();
+ setSelectedIds([]);
+ setSearch('');
+ setQuery({ search: '', pageIndex: 0, pageSize: query.pageSize });
+ }}
+ onRefresh={() => {
+ clearActionFeedback();
+ setSelectedIds([]);
+ if (retryLoad) {
+ retryLoad();
+ return;
+ }
+ setReloadVersion(version => version + 1);
+ }}
+ onNew={() => {
+ clearActionFeedback();
+ setDeleteTarget(null);
+ setParamDraft(null);
+ if (!draftPlugin) {
+ setUploadValidation({});
+ setDraftPlugin(createEmptyPluginDraft());
+ }
+ setIsUploadDialogOpen(true);
+ }}
+ onDeleteSelected={() => {
+ clearActionFeedback();
+ if (selectedIds.length === 0) {
+ setActionKind('delete');
+ setActionTone('warning');
+ setActionError(t('common.notify.no-select-delete'));
+ return;
+ }
+ setActionKind('delete');
+ setDeleteTarget({
+ ids: selectedIds,
+ label: t('setting.plugins.selected-count', { count: selectedIds.length }),
+ mode: 'batch'
+ });
+ }}
+ onDeleteCancel={() => {
+ if (!isDeletePending) setDeleteTarget(null);
+ }}
+ onDeleteConfirm={() => {
+ if (!deleteTarget) return;
+ clearActionFeedback();
+ setActionKind('delete');
+ setIsDeletePending(true);
+ const targetIds = deleteTarget.ids;
+ void deletePlugins(apiMessageDelete, targetIds)
+ .then(() => {
+ const pageSize = data.list.pageSize ?? query.pageSize ?? 8;
+ const currentPageIndex = data.list.pageIndex ?? query.pageIndex ?? 0;
+ const nextPageIndex = clampPluginPageIndexAfterDelete({
+ pageIndex: currentPageIndex + 1,
+ pageSize,
+ totalElements: data.list.totalElements ?? 0,
+ deleteCount: targetIds.length
+ });
+ setActionTone('success');
+ setActionMessage(t('common.notify.delete-success'));
+ setSelectedIds([]);
+ setDeleteTarget(null);
+ setQuery(previous => ({
+ ...previous,
+ pageIndex: nextPageIndex,
+ pageSize
+ }));
+ setReloadVersion(version => version + 1);
+ })
+ .catch(error => {
+ setActionTone('critical');
+ setActionError(t('common.notify.delete-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsDeletePending(false);
+ });
+ }}
+ onSelectedIdsChange={setSelectedIds}
+ onPageIndexChange={pageIndex => {
+ clearActionFeedback();
+ setSelectedIds([]);
+ setQuery(current => ({ ...current, pageIndex }));
+ }}
+ onPageSizeChange={pageSize => {
+ clearActionFeedback();
+ setSelectedIds([]);
+ setQuery(current => ({ ...current, pageIndex: 0, pageSize }));
+ }}
+ onEditParams={(plugin: Plugin) => {
+ clearActionFeedback();
+ setDraftPlugin(null);
+ setDeleteTarget(null);
+ setActionKind('params');
+ setIsParamPending(true);
+ void loadPluginParamDraft(apiMessageGet, plugin, locale)
+ .then(draft => {
+ setParamDraft(draft);
+ })
+ .catch(error => {
+ setActionTone('critical');
+ setActionError(t('common.notify.edit-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsParamPending(false);
+ });
+ }}
+ onParamChange={(field, value) => {
+ setParamDraft(previous => (previous ? updatePluginParamDraft(previous, field, value) : previous));
+ }}
+ onCloseParamDialog={() => {
+ setParamDraft(null);
+ }}
+ onSaveParamDialog={() => {
+ if (!paramDraft) return;
+ clearActionFeedback();
+ setActionKind('params');
+ setIsParamPending(true);
+ void savePluginParams(apiMessagePost, paramDraft.params)
+ .then(() => {
+ setActionTone('success');
+ setActionMessage(t('common.notify.edit-success'));
+ setParamDraft(null);
+ })
+ .catch(error => {
+ setActionTone('critical');
+ setActionError(t('common.notify.edit-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsParamPending(false);
+ });
+ }}
+ onToggleEnabled={plugin => {
+ clearActionFeedback();
+ setActionKind('enable');
+ setOptimisticEnableStatus(current => ({
+ ...current,
+ [plugin.id]: !Boolean(plugin.enableStatus)
+ }));
+ setIsTogglePending(true);
+ void togglePluginStatus(apiMessagePut, plugin)
+ .then(() => {
+ setActionTone('success');
+ setActionMessage(t('common.notify.edit-success'));
+ setSelectedIds([]);
+ setReloadVersion(version => version + 1);
+ })
+ .catch(error => {
+ setActionTone('critical');
+ setActionError(t('common.notify.edit-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsTogglePending(false);
+ });
+ }}
+ onDeleteOne={plugin => {
+ clearActionFeedback();
+ setActionKind('delete');
+ setDeleteTarget({
+ ids: [plugin.id],
+ label: plugin.name,
+ mode: 'single'
+ });
+ }}
+ onDraftChange={patch => {
+ setUploadValidation(previous => ({
+ ...previous,
+ ...(Object.prototype.hasOwnProperty.call(patch, 'name') ? { name: false } : {}),
+ ...(Object.prototype.hasOwnProperty.call(patch, 'jarFile') ? { jarFile: false } : {})
+ }));
+ setDraftPlugin(previous => (previous ? { ...previous, ...patch } : previous));
+ }}
+ onCloseDialog={() => {
+ setIsUploadDialogOpen(false);
+ }}
+ onSaveDialog={() => {
+ if (!draftPlugin) return;
+ clearActionFeedback();
+ setActionKind('upload');
+ const validation = validatePluginUploadDraft(draftPlugin);
+ if (!validation.name || !validation.jarFile) {
+ setUploadValidation({
+ name: !validation.name,
+ jarFile: !validation.jarFile
+ });
+ return;
+ }
+ setUploadValidation({});
+ setIsUploadPending(true);
+ void savePlugin(apiMessagePost, draftPlugin)
+ .then(() => {
+ setActionTone('success');
+ setActionMessage(t('common.notify.new-success'));
+ setUploadValidation({});
+ setIsUploadDialogOpen(false);
+ setDraftPlugin(null);
+ setSelectedIds([]);
+ setReloadVersion(version => version + 1);
+ })
+ .catch(error => {
+ setActionTone('critical');
+ setActionError(t('common.notify.new-fail'));
+ setActionMeta(error instanceof Error ? error.message : null);
+ })
+ .finally(() => {
+ setIsUploadPending(false);
+ });
+ }}
+ />
+ );
+ }
+
+ return (
+ <ClientWorkbench
+ load={load}
+ loadingCopy={t('setting.plugins.loading')}
+ cacheKey={pluginManageCacheKey}
+ cacheSettledTtlMs={SETTING_PLUGINS_SETTLED_CACHE_TTL_MS}
+ renderError={(message, retry) => renderPluginSurface(buildFallbackPluginData(), message, retry)}
+ >
+ {data => renderPluginSurface(data)}
+ </ClientWorkbench>
+ );
+}
diff --git a/web-next/components/pages/collector-manage-surface.test.tsx b/web-next/components/pages/collector-manage-surface.test.tsx
index fe189bfbb2..1fea926bc8 100644
--- a/web-next/components/pages/collector-manage-surface.test.tsx
+++ b/web-next/components/pages/collector-manage-surface.test.tsx
@@ -4,10 +4,10 @@ import { resolve } from 'node:path';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { createTranslatorMock } from '../../test/i18n-test-helper';
+import { CollectorManageSurface } from './collector-manage-surface';
describe('collector manage surface', () => {
- it('renders the cold-matte collector toolbar and dense table shell', async () => {
- const { CollectorManageSurface } = await import('./collector-manage-surface');
+ it('renders the cold-matte collector toolbar and dense table shell', () => {
const t = createTranslatorMock({
locale: 'zh-CN',
overrides: {
@@ -68,8 +68,8 @@ describe('collector manage surface', () => {
dispatchMonitorNum: 3
}
],
- totalElements: 2,
- pageIndex: 0,
+ totalElements: 18,
+ pageIndex: 1,
pageSize: 8
}
}}
@@ -77,6 +77,7 @@ describe('collector manage surface', () => {
formatTime={() => '2026-04-10 18:00:00'}
onSearchChange={() => {}}
onSearch={() => {}}
+ onSearchClear={() => {}}
onRefresh={() => {}}
onDeploy={() => {}}
onGoOnline={() => {}}
@@ -96,17 +97,99 @@ describe('collector manage surface', () => {
expect(html).toContain('data-collector-health-tone="warning"');
expect(html).toContain('data-collector-health-freshness="last-seen"');
expect(html).toContain('data-collector-admin-layout="full-width-admin-list"');
+ expect(html).toContain('data-collector-delete-warning-contract="angular-no-select-warning"');
+ expect(html).toContain('data-collector-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-collector-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-collector-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-delete-api-contract="angular-repeated-collectors-query"');
+ expect(html).toContain('data-collector-delete-page-clamp-contract="angular-update-page-index"');
+ expect(html).toContain('data-collector-delete-page-clamp-owner="route-state-contract"');
+ expect(html).toContain('data-collector-operate-warning-contract="angular-no-select-online-offline"');
+ expect(html).toContain('data-collector-operate-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-collector-operate-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-operate-feedback-contract="angular-operate-notify"');
+ expect(html).toContain('data-collector-operate-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-operate-api-contract="angular-put-repeated-collectors-query"');
+ expect(html).toContain('data-collector-mutation-loading-contract="angular-nz-table-loading"');
+ expect(html).toContain('data-collector-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-collector-load-failure-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-load-failure="none"');
+ expect(html).toContain('data-collector-deploy-modal-contract="angular-generate-identity-modal"');
+ expect(html).toContain('data-collector-deploy-api-contract="angular-post-generate-identity"');
+ expect(html).toContain('data-collector-deploy-failure-contract="angular-apply-fail-notification"');
+ expect(html).toContain('data-collector-deploy-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-collector-deploy-validation-raw-contract="angular-required-before-trim"');
+ expect(html).toContain('data-collector-deploy-validation-raw-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-deploy-mask-contract="angular-mask-closable-false"');
+ expect(html).toContain('data-collector-deploy-width-contract="angular-width-45-percent"');
+ expect(html).toContain('data-collector-deploy-field-layout-contract="angular-label-7-control-12"');
+ expect(html).toContain('data-collector-deploy-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-collector-deploy-command-contract="angular-docker-package-shells"');
+ expect(html).toContain('data-collector-deploy-command-owner="hertzbeat-ui-code-editor"');
+ expect(html).toContain('data-collector-deploy-package-link-contract="angular-github-releases-link"');
+ expect(html).toContain('data-collector-deploy-copy-contract="angular-copy-identity"');
+ expect(html).toContain('data-collector-deploy-copy-feedback-contract="angular-copy-success-duration-800"');
+ expect(html).toContain('data-collector-deploy-result-close-contract="angular-danger-close-after-identity"');
+ expect(html).toContain('data-collector-deploy-result-close-owner="hertzbeat-ui-button"');
+ expect(html).toContain('data-collector-deploy-close-reset-contract="angular-close-clears-name"');
+ expect(html).toContain('data-collector-deploy-close-reset-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-deploy-close-pending-result-contract="angular-close-ignores-late-generate"');
+ expect(html).toContain('data-collector-deploy-close-pending-result-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-pagination="cold-dense-pagination"');
+ expect(html).toContain('data-collector-pagination-owner="hertzbeat-ui-pagination-bar"');
+ expect(html).toContain('data-collector-pagination-contract="angular-search-pagination"');
+ expect(html).toContain('data-hz-ui="pagination-bar"');
+ expect(html).toContain('data-hz-pagination-page-size="select-menu"');
+ expect(html).toContain('data-hz-pagination-page-jump="number-input"');
+ expect(html).toContain('data-collector-pagination-page-size-owner="hertzbeat-ui-select"');
+ expect(html).toContain('data-collector-pagination-page-jump-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-collector-deploy-open="angular-deploy-modal"');
+ expect(html).toContain('data-collector-online-selected="angular-batch-online-entry"');
+ expect(html).toContain('data-collector-offline-selected="angular-batch-offline-entry"');
+ expect(html).toContain('data-collector-delete-selected="angular-batch-delete-entry"');
expect(html).toContain('data-collector-toolbar="cold-table-toolbar"');
expect(html).toContain('data-collector-search-owner="shared-search-row"');
+ expect(html).toContain('data-collector-search-submit-contract="angular-enter-and-clear"');
+ expect(html).toContain('data-collector-search-clear-contract="angular-cleared-load"');
+ expect(html).toContain('data-collector-search-clear-owner="shared-search-row"');
expect(html).toContain('data-cold-search-row-owner="cold-search-row"');
expect(html).toContain('data-cold-search-input="fixed-width-direct"');
expect(html).toContain('data-cold-search-control="direct-input"');
expect(html).toContain('data-cold-search-chrome="no-extra-input-shell"');
expect(html).toContain('data-cold-search-action="submit"');
+ expect(html).toContain('data-cold-search-action="clear"');
expect(html).toContain('data-collector-table-shell="cold-dense-table"');
+ expect(html).toContain('data-collector-table-loading="false"');
+ expect(html).toContain('data-collector-table-loading-contract="angular-load-collectors-table"');
+ expect(html).toContain('data-collector-table-loading-owner="angular-nz-table-loading"');
+ expect(html).toContain('data-collector-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(html).toContain('data-collector-table-loading-source="idle"');
+ expect(html).toContain('aria-busy="false"');
expect(html).toContain('data-collector-manage-table="cold-collector-table"');
+ expect(html).toContain('data-collector-select-all="cold-checkbox"');
+ expect(html).toContain('data-collector-row-select="edge-a"');
+ expect(html).toContain('data-collector-row-select="edge-b"');
expect(html).toContain('data-collector-row-health="collector-status"');
expect(html).toContain('data-collector-row-actions="cold-icon-actions"');
+ expect(html).toContain('data-collector-row-delete-menu="edge-a"');
+ expect(html).toContain('data-collector-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(html).toContain('data-collector-row-delete-menu-layer="overlay-visible-above-panel"');
+ expect(html).toContain('data-collector-row-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-collector-row-delete-menu-open="false"');
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).toContain('data-collector-row-delete-menu-trigger="edge-a"');
+ expect(html).toContain('data-collector-row-delete-menu-panel="edge-a"');
+ expect(html).toContain('data-collector-row-delete-menu-layer-panel="overlay-visible-above-panel"');
+ expect(html).toContain('data-collector-row-delete-menu-clearance-panel="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-collector-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-collector-row-delete-menu-panel-open="false"');
+ expect(html).toContain('data-collector-delete-one="edge-a"');
+ expect(html).toContain('data-collector-delete-one-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-collector-offline-one="edge-a"');
+ expect(html).toContain('data-collector-online-one="edge-b"');
+ expect(html).toContain('data-collector-status-tone="success"');
+ expect(html).toContain('data-collector-status-tone="danger"');
expect(html).not.toContain('data-cold-search-input-shell');
expect(html).not.toContain('data-collector-summary-rail=');
expect(html).toContain('刷新');
@@ -117,6 +200,9 @@ describe('collector manage surface', () => {
expect(html).toContain('采集器 1 / 2 在线');
expect(html).toContain('任务 11 · 离线 1');
expect(html).toContain('最近上报 2026-04-10 18:00:00');
+ expect(html).toContain('第 2 / 3 页 · 9-10 / 18');
+ expect(html).toContain('每页条数');
+ expect(html).toContain('页码');
expect(html).toContain('搜索');
expect(html).toContain('采集集群');
expect(html).toContain('采集器名称');
@@ -146,10 +232,148 @@ describe('collector manage surface', () => {
expect(source).toContain('data-collector-manage-surface="otlp-cold-collector-console"');
expect(source).toContain('data-collector-manage-style-baseline={coldCollectorVisual.canvasName}');
expect(source).toContain('data-collector-admin-layout="full-width-admin-list"');
+ expect(source).toContain('data-collector-delete-warning-contract="angular-no-select-warning"');
+ expect(source).toContain('data-collector-delete-confirm-contract="angular-modal-confirm"');
+ expect(source).toContain('data-collector-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(source).toContain('data-collector-delete-feedback-contract="angular-delete-notify"');
+ expect(source).toContain('data-collector-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-collector-delete-api-contract="angular-repeated-collectors-query"');
+ expect(source).toContain('data-collector-delete-page-clamp-contract="angular-update-page-index"');
+ expect(source).toContain('data-collector-delete-page-clamp-owner="route-state-contract"');
+ expect(source).toContain('data-collector-selection-reset-contract="angular-load-clears-selection"');
+ expect(source).toContain('data-collector-selection-reset-owner="route-state-contract"');
+ expect(source).toContain('data-collector-search-submit-contract="angular-enter-and-clear"');
+ expect(source).toContain('data-collector-search-clear-contract="angular-cleared-load"');
+ expect(source).toContain('data-collector-search-clear-owner="shared-search-row"');
+ expect(source).toContain('data-collector-operate-warning-contract="angular-no-select-online-offline"');
+ expect(source).toContain('data-collector-operate-confirm-contract="angular-modal-confirm"');
+ expect(source).toContain('data-collector-operate-feedback-contract="angular-operate-notify"');
+ expect(source).toContain('data-collector-operate-api-contract="angular-put-repeated-collectors-query"');
+ expect(source).toContain('data-collector-mutation-loading-contract="angular-nz-table-loading"');
+ expect(source).toContain('data-collector-load-failure-contract="angular-console-only-shell"');
+ expect(source).toContain('data-collector-load-failure-owner="collector-route-controller"');
+ expect(source).toContain("data-collector-load-failure={loadError ? 'angular-console-only-shell' : 'none'}");
+ expect(source).toContain('meta={actionMeta ?? undefined}');
+ expect(source).toContain('data-collector-delete-feedback-title');
+ expect(source).toContain('data-collector-delete-feedback-detail');
+ expect(source).toContain('data-collector-operate-feedback-title');
+ expect(source).toContain('data-collector-operate-feedback-detail');
+ expect(source).toContain('const mutationPending = Boolean(isDeletePending)');
+ expect(source).toContain('const tableLoading = Boolean(isLoadPending || mutationPending)');
+ expect(source).toContain('data-collector-deploy-modal-contract="angular-generate-identity-modal"');
+ expect(source).toContain('data-collector-deploy-api-contract="angular-post-generate-identity"');
+ expect(source).toContain('data-collector-deploy-failure-contract="angular-apply-fail-notification"');
+ expect(source).toContain('data-collector-deploy-validation-contract="angular-submit-marks-required"');
+ expect(source).toContain('data-collector-deploy-validation-raw-contract="angular-required-before-trim"');
+ expect(source).toContain('data-collector-deploy-validation-raw-owner="collector-route-controller"');
+ expect(source).toContain('data-collector-deploy-command-contract="angular-docker-package-shells"');
+ expect(source).toContain('data-collector-deploy-command-owner="hertzbeat-ui-code-editor"');
+ expect(source).toContain('data-collector-deploy-package-link-contract="angular-github-releases-link"');
+ expect(source).toContain('data-collector-deploy-package-link="angular-github-releases-link"');
+ expect(source).toContain('href="https://github.com/apache/hertzbeat/releases"');
+ expect(source).toContain('target="_blank"');
+ expect(source).toContain("title={t('common.notify.apply-fail')}");
+ expect(source).toContain('meta={deployError}');
+ expect(source).toContain('data-collector-deploy-feedback="angular-apply-fail-notification"');
+ expect(source).toContain('data-collector-deploy-feedback-title="common.notify.apply-fail"');
+ expect(source).toContain('data-collector-deploy-feedback-detail="backend-message"');
+ expect(source).toContain('data-collector-deploy-copy-contract="angular-copy-identity"');
+ expect(source).toContain('data-collector-deploy-copy-feedback-contract="angular-copy-success-duration-800"');
+ expect(source).toContain('data-collector-deploy-copy-feedback-duration-ms="800"');
+ expect(source).toContain('data-collector-deploy-dialog="angular-generate-identity-modal"');
+ expect(source).toContain('data-collector-deploy-mask-contract="angular-mask-closable-false"');
+ expect(source).toContain('data-collector-deploy-mask-closable="angular-mask-closable-false"');
+ expect(source).toContain('data-collector-deploy-mask-closable-owner="angular-nz-modal"');
+ expect(source).toContain('data-collector-deploy-width-contract="angular-width-45-percent"');
+ expect(source).toContain('data-collector-deploy-width="angular-width-45-percent"');
+ expect(source).toContain('data-collector-deploy-width-owner="angular-nz-modal"');
+ expect(source).toContain('md:w-[45vw] md:max-w-[45vw]');
+ expect(source).toContain('data-collector-deploy-field-layout-contract="angular-label-7-control-12"');
+ expect(source).toContain('data-collector-deploy-field-layout-owner="route-form-field-grid"');
+ expect(source).toContain('data-collector-deploy-field="name"');
+ expect(source).toContain('data-collector-deploy-field-layout="angular-label-7-control-12"');
+ expect(source).toContain('data-collector-deploy-label-span="7"');
+ expect(source).toContain('data-collector-deploy-control-span="12"');
+ expect(source).toContain('md:grid-cols-[7fr_12fr]');
+ expect(source).toContain('data-collector-deploy-name-input="angular-required-name"');
+ expect(source).toContain('data-collector-deploy-name-validation-raw="angular-required-before-trim"');
+ expect(source).toContain('data-collector-deploy-name-validation-raw-owner="collector-route-controller"');
+ expect(source).toContain("data-collector-deploy-name-lock={resolvedDeployIdentity ? 'angular-disable-after-identity' : 'editable-before-identity'}");
+ expect(source).toContain('data-collector-deploy-name-lock-owner="angular-ngmodel-input"');
+ expect(source).toContain("data-collector-deploy-name-disabled={resolvedDeployIdentity ? 'true' : 'false'}");
+ expect(source).toContain('data-collector-deploy-name-validation-state');
+ expect(source).toContain('data-collector-deploy-name-validation-contract="angular-submit-marks-required"');
+ expect(source).toContain('data-collector-deploy-loading-contract="angular-nz-ok-loading"');
+ expect(source).toContain('data-collector-deploy-generate="angular-generate-identity"');
+ expect(source).toContain('data-collector-deploy-generate-validation="angular-click-before-required"');
+ expect(source).toContain("data-collector-deploy-ok-loading={isDeployPending ? 'true' : 'false'}");
+ expect(source).toContain('data-collector-deploy-ok-loading-owner="angular-nz-ok-loading"');
+ expect(source).toContain('disabled={Boolean(isDeployPending || resolvedDeployIdentity)}');
+ expect(source).toContain('data-collector-deploy-result-close-contract="angular-danger-close-after-identity"');
+ expect(source).toContain('data-collector-deploy-result-close-owner="hertzbeat-ui-button"');
+ expect(source).toContain('data-collector-deploy-close-reset-contract="angular-close-clears-name"');
+ expect(source).toContain('data-collector-deploy-close-reset="angular-close-clears-name"');
+ expect(source).toContain('data-collector-deploy-close-reset-owner="collector-route-controller"');
+ expect(source).toContain('data-collector-deploy-result-close-reset="angular-close-clears-name"');
+ expect(source).toContain('data-collector-deploy-result-close-reset-owner="collector-route-controller"');
+ expect(source).toContain('data-collector-deploy-docker-shell="angular-docker-shell"');
+ expect(source).toContain('data-collector-deploy-package-shell="angular-package-shell"');
+ expect(source).toContain('data-collector-online-selected="angular-batch-online-entry"');
+ expect(source).toContain('data-collector-offline-selected="angular-batch-offline-entry"');
+ expect(source).toContain('data-collector-delete-selected="angular-batch-delete-entry"');
+ expect(source).toContain('HzConfirmDialog');
+ expect(source).toContain('HzInlineFeedback');
+ expect(source).toContain('HzPaginationBar');
+ expect(source).toContain('HzTableRowActionButton');
+ expect(source).toContain('pageSizeOptions = [8, 15, 25]');
+ expect(source).toContain('data-collector-pagination="cold-dense-pagination"');
+ expect(source).toContain('data-collector-pagination-owner="hertzbeat-ui-pagination-bar"');
+ expect(source).toContain('data-collector-pagination-contract="angular-search-pagination"');
+ expect(source).toContain('data-collector-pagination-page-size-owner');
+ expect(source).toContain('data-collector-pagination-page-jump-owner');
+ expect(source).toContain("t('collector.pagination.summary'");
+ expect(source).toContain("t('collector.pagination.page-size')");
+ expect(source).toContain("t('collector.pagination.page')");
+ expect(source).toContain('data-collector-select-all="cold-checkbox"');
+ expect(source).toContain('data-collector-select-all-contract="angular-header-includes-disabled-default"');
+ expect(source).toContain('data-collector-selectable-names={collectorNames.join');
+ expect(source).toContain('data-collector-immutable-names={immutableCollectorNames.join');
+ expect(source).toContain('event.target.checked ? collectorNames : []');
+ expect(source).toContain('data-collector-row-select={row.name}');
+ expect(source).toContain("row.canMutate ? 'angular-selectable' : 'angular-disabled-visual-only'");
+ expect(source).toContain('checked={row.canMutate && resolvedSelectedCollectors.includes(row.name)}');
+ expect(source).toContain('data-collector-online-one={row.name}');
+ expect(source).toContain('data-collector-offline-one={row.name}');
+ expect(source).toContain('const [openRowActionMenuId, setOpenRowActionMenuId] = React.useState<string | null>(null)');
+ expect(source).toContain('const isRowActionMenuOpen = openRowActionMenuId === rowActionMenuId');
+ expect(source).toContain('data-collector-row-delete-menu={rowActionMenuId}');
+ expect(source).toContain('data-collector-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(source).toContain('data-collector-row-delete-menu-layer="overlay-visible-above-panel"');
+ expect(source).toContain('data-collector-row-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(source).toContain("data-collector-row-delete-menu-open={isRowActionMenuOpen ? 'true' : 'false'}");
+ expect(source).toContain('aria-expanded={isRowActionMenuOpen}');
+ expect(source).toContain('data-collector-row-delete-menu-trigger={rowActionMenuId}');
+ expect(source).toContain('data-collector-row-delete-menu-panel={rowActionMenuId}');
+ expect(source).toContain('data-collector-row-delete-menu-layer-panel="overlay-visible-above-panel"');
+ expect(source).toContain('data-collector-row-delete-menu-clearance-panel="floating-overlay-no-panel-crop"');
+ expect(source).toContain('data-collector-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain("data-collector-row-delete-menu-panel-open={isRowActionMenuOpen ? 'true' : 'false'}");
+ expect(source).not.toContain('<details');
+ expect(source).not.toContain('</details>');
+ expect(source).toContain('collectorActionMenuRootClassName');
+ expect(source).toContain('collectorActionMenuPanelBaseClassName');
+ expect(source).toContain('data-collector-delete-one={row.name}');
+ expect(source).toContain('data-collector-delete-one-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain('data-collector-delete-confirm="angular-modal-confirm"');
expect(source).toContain('data-collector-health-evidence="cluster-status"');
expect(source).toContain('data-collector-health-freshness="last-seen"');
expect(source).toContain('data-collector-row-health="collector-status"');
expect(source).toContain('data-collector-table-shell="cold-dense-table"');
+ expect(source).toContain("data-collector-table-loading={tableLoading ? 'true' : 'false'}");
+ expect(source).toContain('data-collector-table-loading-contract="angular-load-collectors-table"');
+ expect(source).toContain('data-collector-table-loading-owner="angular-nz-table-loading"');
+ expect(source).toContain('data-collector-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(source).toContain("data-collector-table-loading-source={tableLoading ? 'route-load-or-mutation' : 'idle'}");
expect(source).toContain('data-collector-manage-table="cold-collector-table"');
expect(source).not.toContain('data-collector-summary-rail');
expect(source).not.toContain('coldCollectorVisual.search.row');
@@ -180,4 +404,602 @@ describe('collector manage surface', () => {
expect(source).toContain('text-[#a9b0bb]');
expect(source).toContain('text-[#858d9a]');
});
+
+ it('keeps the table busy while route reloads run like Angular loadCollectorsTable', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ isLoadPending
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-table-loading="true"');
+ expect(html).toContain('data-collector-table-loading-contract="angular-load-collectors-table"');
+ expect(html).toContain('data-collector-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(html).toContain('data-collector-table-loading-source="route-load-or-mutation"');
+ expect(html).toContain('aria-busy="true"');
+ });
+
+ it('keeps the collector shell mounted after list load failure without rendering backend text', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ loadError="backend refused collector load"
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-manage-surface="otlp-cold-collector-console"');
+ expect(html).toContain('data-collector-admin-layout="full-width-admin-list"');
+ expect(html).toContain('data-collector-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-collector-load-failure-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-load-failure="angular-console-only-shell"');
+ expect(html).toContain('data-collector-table-shell="cold-dense-table"');
+ expect(html).not.toContain('backend refused collector load');
+ });
+
+ it('renders Angular collector delete warning, confirmation, and feedback contracts', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ collector: {
+ name: 'edge-a',
+ ip: '10.0.0.1',
+ status: 0,
+ mode: 'public',
+ version: '1.0.0',
+ gmtUpdate: '2026-04-10T10:00:00Z'
+ },
+ pinMonitorNum: 2,
+ dispatchMonitorNum: 5
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedCollectors={['edge-a']}
+ actionError="请先选择要删除的监控"
+ actionTone="warning"
+ deleteTarget={{
+ collectors: ['edge-a'],
+ label: 'edge-a',
+ mode: 'single'
+ }}
+ isDeletePending
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onSelectedCollectorsChange={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-delete-warning-contract="angular-no-select-warning"');
+ expect(html).toContain('data-collector-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-collector-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-collector-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-delete-api-contract="angular-repeated-collectors-query"');
+ expect(html).toContain('data-collector-delete-page-clamp-contract="angular-update-page-index"');
+ expect(html).toContain('data-collector-delete-page-clamp-owner="route-state-contract"');
+ expect(html).toContain('data-collector-delete-selected="angular-batch-delete-entry"');
+ expect(html).toContain('data-collector-delete-feedback="angular-no-select-warning"');
+ expect(html).toContain('data-collector-delete-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-delete-confirm="angular-modal-confirm"');
+ expect(html).toContain('data-collector-delete-confirm-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-delete-confirm-submit="angular-modal-confirm"');
+ expect(html).toContain('data-collector-table-loading="true"');
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('data-collector-delete-confirm-target="edge-a"');
+ expect(html).toContain('data-collector-row-select="edge-a"');
+ expect(html).toContain('data-collector-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(html).toContain('data-collector-row-delete-menu-layer="overlay-visible-above-panel"');
+ expect(html).toContain('data-collector-row-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-collector-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-collector-row-delete-menu-panel-open="false"');
+ expect(html).toContain('data-collector-delete-one="edge-a"');
+ expect(html).toContain('请先选择要删除的监控');
+ expect(html).toContain('请确认是否删除!');
+ });
+
+ it('renders Angular collector delete and operate failure titles with backend detail metadata', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const deleteFailureHtml = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ actionError="删除失败!"
+ actionMeta="backend delete refused"
+ actionTone="critical"
+ actionFeedbackContract="angular-delete-fail"
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(deleteFailureHtml).toContain('data-collector-delete-feedback="angular-delete-fail"');
+ expect(deleteFailureHtml).toContain('data-collector-delete-feedback-title="common.notify.delete-fail"');
+ expect(deleteFailureHtml).toContain('data-collector-delete-feedback-detail="backend-message"');
+ expect(deleteFailureHtml).toContain('删除失败!');
+ expect(deleteFailureHtml).toContain('backend delete refused');
+
+ const operateFailureHtml = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ actionError="操作失败!"
+ actionMeta="backend operate refused"
+ actionTone="critical"
+ actionFeedbackContract="angular-operate-fail"
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(operateFailureHtml).toContain('data-collector-operate-feedback="angular-operate-fail"');
+ expect(operateFailureHtml).toContain('data-collector-operate-feedback-title="common.notify.operate-fail"');
+ expect(operateFailureHtml).toContain('data-collector-operate-feedback-detail="backend-message"');
+ expect(operateFailureHtml).toContain('操作失败!');
+ expect(operateFailureHtml).toContain('backend operate refused');
+ });
+
+ it('renders Angular collector online/offline warning, confirmation, and feedback contracts', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ collector: {
+ name: 'edge-a',
+ ip: '10.0.0.1',
+ status: 0,
+ mode: 'public',
+ version: '1.0.0',
+ gmtUpdate: '2026-04-10T10:00:00Z'
+ },
+ pinMonitorNum: 2,
+ dispatchMonitorNum: 5
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedCollectors={['edge-a']}
+ actionError="未选中任何待下线项!"
+ actionTone="warning"
+ actionFeedbackContract="angular-no-select-offline"
+ deleteTarget={{
+ action: 'offline',
+ collectors: ['edge-a'],
+ label: 'edge-a',
+ mode: 'single'
+ }}
+ isDeletePending
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onSelectedCollectorsChange={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-operate-warning-contract="angular-no-select-online-offline"');
+ expect(html).toContain('data-collector-operate-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-collector-operate-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-operate-feedback-contract="angular-operate-notify"');
+ expect(html).toContain('data-collector-operate-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-operate-api-contract="angular-put-repeated-collectors-query"');
+ expect(html).toContain('data-collector-action-feedback-contract="angular-no-select-offline"');
+ expect(html).toContain('data-collector-operate-feedback="angular-no-select-offline"');
+ expect(html).toContain('data-collector-operate-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-operate-confirm="angular-modal-confirm"');
+ expect(html).toContain('data-collector-operate-confirm-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-collector-operate-confirm-submit="angular-modal-confirm"');
+ expect(html).toContain('data-collector-table-loading="true"');
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('data-collector-delete-confirm-target="edge-a"');
+ expect(html).toContain('data-collector-offline-one="edge-a"');
+ expect(html).toContain('未选中任何待下线项!');
+ expect(html).toContain('请确认是否下线此采集器!');
+ });
+
+ it('keeps the Angular header select-all batch payload while the default row stays visually disabled', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ collector: {
+ name: 'main-default-collector',
+ ip: '127.0.0.1',
+ status: 0,
+ mode: 'public',
+ version: '2.0.0',
+ gmtUpdate: '2026-04-10T10:00:00Z'
+ },
+ pinMonitorNum: 5,
+ dispatchMonitorNum: 8
+ },
+ {
+ collector: {
+ name: 'edge-a',
+ ip: '10.0.0.1',
+ status: 1,
+ mode: 'private',
+ version: '1.0.0',
+ gmtUpdate: '2026-04-09T10:00:00Z'
+ },
+ pinMonitorNum: 2,
+ dispatchMonitorNum: 5
+ }
+ ],
+ totalElements: 2,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedCollectors={['edge-a']}
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onSelectedCollectorsChange={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-select-all-contract="angular-header-includes-disabled-default"');
+ expect(html).toContain('data-collector-selectable-names="main-default-collector,edge-a"');
+ expect(html).toContain('data-collector-immutable-names="main-default-collector"');
+ expect(html).toContain('data-collector-row-select="main-default-collector"');
+ expect(html).toContain('data-collector-row-select-contract="angular-disabled-visual-only"');
+ expect(html).toContain('data-collector-row-select="edge-a"');
+ expect(html).toContain('data-collector-row-select-contract="angular-selectable"');
+ });
+
+ it('renders Angular collector deploy identity modal and code editor contracts', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ deployOpen
+ deployName="edge-a"
+ deployIdentity="identity-123"
+ deployHost="10.0.0.10"
+ deployDockerShell="$ docker run -d -e IDENTITY=identity-123"
+ deployPackageShell="identity: identity-123\nmanager-host: 10.0.0.10"
+ deployCopyMessage="复制成功!"
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onDeployNameChange={() => {}}
+ onDeployGenerate={() => {}}
+ onDeployClose={() => {}}
+ onDeployCopy={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-deploy-dialog="angular-generate-identity-modal"');
+ expect(html).toContain('data-collector-deploy-mask-closable="angular-mask-closable-false"');
+ expect(html).toContain('data-collector-deploy-mask-closable-owner="angular-nz-modal"');
+ expect(html).toContain('data-collector-deploy-width="angular-width-45-percent"');
+ expect(html).toContain('data-collector-deploy-width-owner="angular-nz-modal"');
+ expect(html).toContain('data-collector-deploy-field="name"');
+ expect(html).toContain('data-collector-deploy-field-layout="angular-label-7-control-12"');
+ expect(html).toContain('data-collector-deploy-label-span="7"');
+ expect(html).toContain('data-collector-deploy-control-span="12"');
+ expect(html).toContain('data-collector-deploy-name-input="angular-required-name"');
+ expect(html).toContain('data-collector-deploy-name-lock="angular-disable-after-identity"');
+ expect(html).toContain('data-collector-deploy-name-lock-owner="angular-ngmodel-input"');
+ expect(html).toContain('data-collector-deploy-name-disabled="true"');
+ expect(html).toContain('disabled=""');
+ expect(html).toContain('data-collector-deploy-result="angular-identity-shells"');
+ expect(html).toContain('data-collector-deploy-copy-identity="angular-copy-identity"');
+ expect(html).toContain('data-collector-deploy-copy-feedback="angular-copy-success"');
+ expect(html).toContain('data-collector-deploy-result-close-contract="angular-danger-close-after-identity"');
+ expect(html).toContain('data-collector-deploy-copy-feedback-duration-ms="800"');
+ expect(html).toContain('data-collector-deploy-result-close-owner="hertzbeat-ui-button"');
+ expect(html).toContain('data-collector-deploy-close-reset="angular-close-clears-name"');
+ expect(html).toContain('data-collector-deploy-close-reset-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-deploy-close-pending-result="angular-close-ignores-late-generate"');
+ expect(html).toContain('data-collector-deploy-close-pending-result-owner="collector-route-controller"');
+ expect(html).toContain('data-collector-deploy-result-close-reset="angular-close-clears-name"');
+ expect(html).toContain('data-collector-deploy-result-close-reset-owner="collector-route-controller"');
+ expect(html).toContain('data-hz-button-tier="solid-danger"');
+ expect(html).toContain('data-collector-deploy-docker-shell="angular-docker-shell"');
+ expect(html).toContain('data-collector-deploy-package-shell="angular-package-shell"');
+ expect(html).toContain('data-collector-deploy-code-owner="hertzbeat-ui-code-editor"');
+ expect(html).toContain('data-collector-deploy-package-link="angular-github-releases-link"');
+ expect(html).toContain('data-collector-deploy-package-link-owner="angular-nz-modal"');
+ expect(html).toContain('href="https://github.com/apache/hertzbeat/releases"');
+ expect(html).toContain('target="_blank"');
+ expect(html).toContain('identity-123');
+ expect(html).toContain('10.0.0.10');
+ expect(html).toContain('通过 Docker 方式部署');
+ expect(html).toContain('通过安装包部署');
+ });
+
+ it('keeps collector deploy required-name validation submit-triggered like Angular', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const pristineHtml = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ deployOpen
+ deployName=""
+ deployNameValidationVisible={false}
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onDeployNameChange={() => {}}
+ onDeployGenerate={() => {}}
+ onDeployClose={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+ const dirtyHtml = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ deployOpen
+ deployName=""
+ deployNameValidationVisible
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onDeployNameChange={() => {}}
+ onDeployGenerate={() => {}}
+ onDeployClose={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(pristineHtml).toContain('data-collector-deploy-validation-contract="angular-submit-marks-required"');
+ expect(pristineHtml).toContain('data-collector-deploy-validation-raw-contract="angular-required-before-trim"');
+ expect(pristineHtml).toContain('data-collector-deploy-name-validation-state="pristine"');
+ expect(pristineHtml).toContain('data-collector-deploy-name-validation-raw="angular-required-before-trim"');
+ expect(pristineHtml).toContain('data-collector-deploy-name-lock="editable-before-identity"');
+ expect(pristineHtml).toContain('data-collector-deploy-name-disabled="false"');
+ expect(pristineHtml).toContain('data-collector-deploy-generate-validation="angular-click-before-required"');
+ expect(pristineHtml).not.toContain('data-collector-deploy-name-validation="validation.required"');
+ expect(dirtyHtml).toContain('data-collector-deploy-name-validation-state="dirty-invalid"');
+ expect(dirtyHtml).toContain('data-collector-deploy-name-validation="validation.required"');
+ expect(dirtyHtml).toContain('data-collector-deploy-name-validation-contract="angular-submit-marks-required"');
+ expect(dirtyHtml).toContain('请填充必填项!');
+ });
+
+ it('keeps collector deploy OK loading visible while identity generation is pending', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ deployOpen
+ deployName="edge-a"
+ isDeployPending
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onDeployNameChange={() => {}}
+ onDeployGenerate={() => {}}
+ onDeployClose={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-deploy-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-collector-deploy-ok-loading="true"');
+ expect(html).toContain('data-collector-deploy-ok-loading-owner="angular-nz-ok-loading"');
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('disabled=""');
+ expect(html).toContain('加载中');
+ });
+
+ it('keeps collector deploy generate failures as Angular apply-fail notifications', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <CollectorManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ deployOpen
+ deployName="edge-a"
+ deployError="collector already exists"
+ formatTime={() => '2026-04-10 18:00:00'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onDeploy={() => {}}
+ onGoOnline={() => {}}
+ onGoOffline={() => {}}
+ onDelete={() => {}}
+ onDeployNameChange={() => {}}
+ onDeployGenerate={() => {}}
+ onDeployClose={() => {}}
+ onRowGoOnline={() => {}}
+ onRowGoOffline={() => {}}
+ onRowDelete={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-collector-deploy-failure-contract="angular-apply-fail-notification"');
+ expect(html).toContain('data-collector-deploy-feedback="angular-apply-fail-notification"');
+ expect(html).toContain('data-collector-deploy-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-collector-deploy-feedback-title="common.notify.apply-fail"');
+ expect(html).toContain('data-collector-deploy-feedback-detail="backend-message"');
+ expect(html).toContain('collector already exists');
+ });
});
diff --git a/web-next/components/pages/collector-manage-surface.tsx b/web-next/components/pages/collector-manage-surface.tsx
index 950313a96f..2aedcc3523 100644
--- a/web-next/components/pages/collector-manage-surface.tsx
+++ b/web-next/components/pages/collector-manage-surface.tsx
@@ -1,27 +1,68 @@
'use client';
import React from 'react';
-import { Cloud, Inbox, Power, PowerOff, RefreshCw, Trash2, UploadCloud } from 'lucide-react';
+import { Cloud, Copy, Inbox, MoreHorizontal, Power, PowerOff, RefreshCw, Trash2, UploadCloud, X } from 'lucide-react';
+import { HzButton, HzCodeEditor, HzConfirmDialog, HzInlineFeedback, HzPaginationBar, HzTableRowActionButton } from '@hertzbeat/ui';
import { Button } from '../ui/button';
+import { Checkbox } from '../ui/checkbox';
import { SearchRow } from '../ui/search-row';
import { coldOpsCatalogVisual } from '../../lib/cold-ops-visual';
import { buildCollectorClusterHealthEvidence, buildCollectorTableRows } from '../../lib/collector-manage/view-model';
import type { CollectorSummary, PageResult } from '../../lib/types';
type Translator = (key: string, params?: Record<string, string | number | null | undefined>) => string;
+type CollectorActionTone = 'success' | 'warning' | 'critical';
+type CollectorMutationAction = 'delete' | 'online' | 'offline';
+export type CollectorDeleteTarget = {
+ collectors: string[];
+ label: string;
+ mode: 'single' | 'batch';
+ action?: CollectorMutationAction;
+};
type CollectorManageSurfaceProps = {
t: Translator;
data: { list: PageResult<CollectorSummary> };
search: string;
+ selectedCollectors?: string[];
+ actionMessage?: string | null;
+ actionError?: string | null;
+ actionMeta?: string | null;
+ actionTone?: CollectorActionTone;
+ actionFeedbackContract?: string | null;
+ loadError?: string | null;
+ deleteTarget?: CollectorDeleteTarget | null;
+ isDeletePending?: boolean;
+ isLoadPending?: boolean;
+ deployOpen?: boolean;
+ deployName?: string;
+ deployIdentity?: string;
+ deployHost?: string;
+ deployDockerShell?: string;
+ deployPackageShell?: string;
+ deployError?: string | null;
+ deployCopyMessage?: string | null;
+ isDeployPending?: boolean;
+ deployNameValidationVisible?: boolean;
formatTime: (value?: number | string | null) => string;
onSearchChange: (value: string) => void;
onSearch: () => void;
+ onSearchClear: () => void;
onRefresh: () => void;
onDeploy: () => void;
onGoOnline: () => void;
onGoOffline: () => void;
onDelete: () => void;
+ onSelectedCollectorsChange?: (collectors: string[]) => void;
+ pageSizeOptions?: number[];
+ onPageIndexChange?: (pageIndex: number) => void;
+ onPageSizeChange?: (pageSize: number) => void;
+ onDeleteCancel?: () => void;
+ onDeleteConfirm?: () => void;
+ onDeployNameChange?: (value: string) => void;
+ onDeployGenerate?: () => void;
+ onDeployClose?: () => void;
+ onDeployCopy?: (value: string) => void;
onRowGoOnline: (collector: string) => void;
onRowGoOffline: (collector: string) => void;
onRowDelete: (collector: string) => void;
@@ -38,14 +79,29 @@ const coldPrimaryButtonClassName =
const coldIconButtonClassName =
'h-8 w-8 min-w-0 rounded-[3px] border-[#2b3039] bg-[#101217] text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white disabled:opacity-40';
-function statusClassName(statusLabel: string) {
- if (statusLabel.includes('在线') || statusLabel.toLowerCase().includes('online')) {
+const collectorActionMenuRootClassName = 'relative z-40 inline-block overflow-visible';
+const collectorActionMenuPanelBaseClassName =
+ 'absolute top-9 z-[100] min-w-[132px] rounded-[3px] border border-[#2b3039] bg-[#101217] p-1 shadow-[0_18px_42px_rgba(0,0,0,0.42)]';
+
+function statusClassName(statusTone: 'success' | 'danger') {
+ if (statusTone === 'success') {
return 'border-[#166534]/45 bg-[#0f2f23] text-[#86efac]';
}
return 'border-[#7f1d1d]/55 bg-[#2a1214] text-[#fca5a5]';
}
-function EmptyTableRow({ colSpan }: { colSpan: number }) {
+function resolveCollectorTargetAction(target?: CollectorDeleteTarget | null): CollectorMutationAction {
+ return target?.action ?? 'delete';
+}
+
+function resolveCollectorConfirmTitle(target: CollectorDeleteTarget | null | undefined, t: Translator) {
+ const action = resolveCollectorTargetAction(target);
+ if (action === 'online') return t(target?.mode === 'batch' ? 'collector.confirm.online-batch' : 'collector.confirm.online');
+ if (action === 'offline') return t(target?.mode === 'batch' ? 'collector.confirm.offline-batch' : 'collector.confirm.offline');
+ return t(target?.mode === 'batch' ? 'common.confirm.delete-batch' : 'common.confirm.delete');
+}
+
+function EmptyTableRow({ colSpan, t }: { colSpan: number; t: Translator }) {
return (
<tr data-collector-empty-state="cold-table-empty" className="bg-[#0b0c0e]">
<td colSpan={colSpan} className="h-[240px] px-3 py-10 text-center text-[#858d9a]">
@@ -53,7 +109,7 @@ function EmptyTableRow({ colSpan }: { colSpan: number }) {
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[4px] border border-[#303743] bg-[#101217] text-[#cbd5e1]">
<Inbox className="h-5 w-5" aria-hidden="true" />
</span>
- <div className="text-[13px] font-semibold text-[#dbe4f0]">暂无数据</div>
+ <div className="text-[13px] font-semibold text-[#dbe4f0]">{t('common.no-data')}</div>
</div>
</td>
</tr>
@@ -64,30 +120,99 @@ export function CollectorManageSurface({
t,
data,
search,
+ selectedCollectors,
+ actionMessage,
+ actionError,
+ actionMeta,
+ actionTone,
+ actionFeedbackContract,
+ loadError,
+ deleteTarget,
+ isDeletePending,
+ isLoadPending,
+ deployOpen,
+ deployName,
+ deployIdentity,
+ deployHost,
+ deployDockerShell,
+ deployPackageShell,
+ deployError,
+ deployCopyMessage,
+ isDeployPending,
+ deployNameValidationVisible,
formatTime,
onSearchChange,
onSearch,
+ onSearchClear,
onRefresh,
onDeploy,
onGoOnline,
onGoOffline,
onDelete,
+ onSelectedCollectorsChange,
+ pageSizeOptions = [8, 15, 25],
+ onPageIndexChange,
+ onPageSizeChange,
+ onDeleteCancel,
+ onDeleteConfirm,
+ onDeployNameChange,
+ onDeployGenerate,
+ onDeployClose,
+ onDeployCopy,
onRowGoOnline,
onRowGoOffline,
onRowDelete
}: CollectorManageSurfaceProps) {
const collectors = data.list.content ?? [];
const rows = buildCollectorTableRows(collectors, t, formatTime);
- const clusterHealth = buildCollectorClusterHealthEvidence(collectors, formatTime);
+ const clusterHealth = buildCollectorClusterHealthEvidence(collectors, formatTime, t);
+ const collectorNames = rows.map(row => row.name);
+ const immutableCollectorNames = rows.filter(row => !row.canMutate).map(row => row.name);
+ const resolvedSelectedCollectors = selectedCollectors ?? [];
+ const currentPageIndex = Math.max(0, data.list.pageIndex ?? 0);
+ const currentPageSize = Math.max(1, data.list.pageSize ?? pageSizeOptions[0] ?? 8);
+ const totalElements = data.list.totalElements || 0;
+ const totalPages = Math.max(1, Math.ceil(totalElements / currentPageSize));
+ const currentPage = Math.min(currentPageIndex + 1, totalPages);
+ const pageStart = totalElements === 0 || rows.length === 0 ? 0 : currentPageIndex * currentPageSize + 1;
+ const pageEnd = totalElements === 0 ? 0 : Math.min(totalElements, currentPageIndex * currentPageSize + rows.length);
+ const allSelected = collectorNames.length > 0 && collectorNames.every(name => resolvedSelectedCollectors.includes(name));
+ const actionFeedbackTitle = actionError ?? actionMessage ?? '';
+ const resolvedActionTone = actionTone ?? (actionError ? 'critical' : 'success');
+ const targetAction = resolveCollectorTargetAction(deleteTarget);
+ const mutationPending = Boolean(isDeletePending);
+ const tableLoading = Boolean(isLoadPending || mutationPending);
+ const resolvedActionFeedbackContract =
+ actionFeedbackContract ??
+ (resolvedActionTone === 'warning' ? 'angular-no-select-warning' : resolvedActionTone === 'critical' ? 'angular-delete-fail' : 'angular-delete-notify');
+ const [openRowActionMenuId, setOpenRowActionMenuId] = React.useState<string | null>(null);
+ const trimmedDeployName = deployName?.trim() ?? '';
+ const resolvedDeployIdentity = deployIdentity ?? '';
+ const showDeployNameValidation = Boolean(deployNameValidationVisible && !trimmedDeployName && !resolvedDeployIdentity);
+ const paginationSummary = t('collector.pagination.summary', {
+ page: currentPage,
+ totalPages,
+ from: pageStart,
+ to: pageEnd,
+ total: totalElements
+ });
+
+ function handlePageJumpChange(value: string) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) return;
+ const nextPageIndex = Math.min(Math.max(parsed, 1), totalPages) - 1;
+ onPageIndexChange?.(nextPageIndex);
+ }
return (
- <div
- data-collector-manage-surface="otlp-cold-collector-console"
- data-collector-manage-style-baseline={coldCollectorVisual.canvasName}
- className={coldCollectorVisual.canvas.root}
- style={coldCollectorVisual.canvas.backgroundStyle}
- >
- <section className={coldCollectorVisual.layout.pageSection}>
+ <>
+ <div
+ data-collector-manage-surface="otlp-cold-collector-console"
+ data-collector-manage-style-baseline={coldCollectorVisual.canvasName}
+ className={coldCollectorVisual.canvas.root}
+ style={coldCollectorVisual.canvas.backgroundStyle}
+ >
+ <section className={coldCollectorVisual.layout.pageSection}>
<div className="mx-auto max-w-[1480px]">
<div className="mb-5">
<div data-collector-header="cold-compact-header" className={coldCollectorVisual.panel.hero}>
@@ -96,26 +221,50 @@ export function CollectorManageSurface({
{t('menu.advanced.collector')}
</h1>
<p className="mt-4 max-w-[780px] text-[13px] leading-6 text-[#a9b0bb]">
- 管理已注册采集器节点、运行状态和任务分布,保持部署、搜索、上下线和删除操作在同一个高密度后台工作面中完成。
+ {t('setting.collector.copy')}
</p>
<div data-collector-command-row="standard-equal-buttons" className={coldCollectorVisual.button.row}>
<Button size="sm" variant="default" className={coldButtonClassName} onClick={onRefresh}>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t('common.refresh')}
</Button>
- <Button size="sm" variant="default" className={coldPrimaryButtonClassName} onClick={onDeploy}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldPrimaryButtonClassName}
+ onClick={onDeploy}
+ data-collector-deploy-open="angular-deploy-modal"
+ >
<UploadCloud className="h-3.5 w-3.5" aria-hidden="true" />
{t('collector.deploy')}
</Button>
- <Button size="sm" variant="default" className={coldButtonClassName} onClick={onGoOnline}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldButtonClassName}
+ onClick={onGoOnline}
+ data-collector-online-selected="angular-batch-online-entry"
+ >
<Power className="h-3.5 w-3.5" aria-hidden="true" />
{t('collector.online')}
</Button>
- <Button size="sm" variant="default" className={coldButtonClassName} onClick={onGoOffline}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldButtonClassName}
+ onClick={onGoOffline}
+ data-collector-offline-selected="angular-batch-offline-entry"
+ >
<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />
{t('collector.offline')}
</Button>
- <Button size="sm" variant="default" className={coldButtonClassName} onClick={onDelete}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldButtonClassName}
+ onClick={onDelete}
+ data-collector-delete-selected="angular-batch-delete-entry"
+ >
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
{t('collector.delete')}
</Button>
@@ -146,7 +295,55 @@ export function CollectorManageSurface({
</div>
</div>
- <div data-collector-admin-layout="full-width-admin-list" className="space-y-5">
+ <div
+ data-collector-admin-layout="full-width-admin-list"
+ data-collector-delete-warning-contract="angular-no-select-warning"
+ data-collector-delete-confirm-contract="angular-modal-confirm"
+ data-collector-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-collector-delete-feedback-contract="angular-delete-notify"
+ data-collector-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-collector-delete-api-contract="angular-repeated-collectors-query"
+ data-collector-delete-page-clamp-contract="angular-update-page-index"
+ data-collector-delete-page-clamp-owner="route-state-contract"
+ data-collector-selection-reset-contract="angular-load-clears-selection"
+ data-collector-selection-reset-owner="route-state-contract"
+ data-collector-search-submit-contract="angular-enter-and-clear"
+ data-collector-search-clear-contract="angular-cleared-load"
+ data-collector-search-clear-owner="shared-search-row"
+ data-collector-operate-warning-contract="angular-no-select-online-offline"
+ data-collector-operate-confirm-contract="angular-modal-confirm"
+ data-collector-operate-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-collector-operate-feedback-contract="angular-operate-notify"
+ data-collector-operate-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-collector-operate-api-contract="angular-put-repeated-collectors-query"
+ data-collector-mutation-loading-contract="angular-nz-table-loading"
+ data-collector-load-failure-contract="angular-console-only-shell"
+ data-collector-load-failure-owner="collector-route-controller"
+ data-collector-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-collector-deploy-modal-contract="angular-generate-identity-modal"
+ data-collector-deploy-api-contract="angular-post-generate-identity"
+ data-collector-deploy-failure-contract="angular-apply-fail-notification"
+ data-collector-deploy-validation-contract="angular-submit-marks-required"
+ data-collector-deploy-validation-raw-contract="angular-required-before-trim"
+ data-collector-deploy-validation-raw-owner="collector-route-controller"
+ data-collector-deploy-loading-contract="angular-nz-ok-loading"
+ data-collector-deploy-mask-contract="angular-mask-closable-false"
+ data-collector-deploy-width-contract="angular-width-45-percent"
+ data-collector-deploy-field-layout-contract="angular-label-7-control-12"
+ data-collector-deploy-field-layout-owner="route-form-field-grid"
+ data-collector-deploy-command-contract="angular-docker-package-shells"
+ data-collector-deploy-command-owner="hertzbeat-ui-code-editor"
+ data-collector-deploy-package-link-contract="angular-github-releases-link"
+ data-collector-deploy-copy-contract="angular-copy-identity"
+ data-collector-deploy-copy-feedback-contract="angular-copy-success-duration-800"
+ data-collector-deploy-result-close-contract="angular-danger-close-after-identity"
+ data-collector-deploy-result-close-owner="hertzbeat-ui-button"
+ data-collector-deploy-close-reset-contract="angular-close-clears-name"
+ data-collector-deploy-close-reset-owner="collector-route-controller"
+ data-collector-deploy-close-pending-result-contract="angular-close-ignores-late-generate"
+ data-collector-deploy-close-pending-result-owner="collector-route-controller"
+ className="space-y-5"
+ >
<section className="min-w-0">
<SearchRow
data-collector-toolbar="cold-table-toolbar"
@@ -154,18 +351,75 @@ export function CollectorManageSurface({
value={search}
placeholder={t('collector.name')}
searchLabel={t('common.search')}
+ clearLabel={t('common.clear')}
inputWidthClassName="w-[360px]"
onValueChange={onSearchChange}
onSearch={onSearch}
+ onClear={onSearchClear}
+ data-collector-search-submit-contract="angular-enter-and-clear"
+ data-collector-search-clear-contract="angular-cleared-load"
+ data-collector-search-clear-owner="shared-search-row"
+ trailingActions={
+ <div className="text-[12px] font-semibold text-[#858d9a]">
+ {t('setting.collector.selected-count', { count: resolvedSelectedCollectors.length })}
+ </div>
+ }
/>
+ {actionFeedbackTitle ? (
+ <HzInlineFeedback
+ tone={resolvedActionTone}
+ title={actionFeedbackTitle}
+ meta={actionMeta ?? undefined}
+ variant="embedded"
+ data-collector-action-feedback={resolvedActionTone}
+ data-collector-action-feedback-contract={resolvedActionFeedbackContract}
+ data-collector-delete-feedback={
+ resolvedActionFeedbackContract.startsWith('angular-delete') || resolvedActionFeedbackContract === 'angular-no-select-warning'
+ ? resolvedActionFeedbackContract
+ : undefined
+ }
+ data-collector-delete-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-collector-delete-feedback-title={resolvedActionFeedbackContract === 'angular-delete-fail' ? 'common.notify.delete-fail' : undefined}
+ data-collector-delete-feedback-detail={resolvedActionFeedbackContract === 'angular-delete-fail' && actionMeta ? 'backend-message' : undefined}
+ data-collector-operate-feedback={
+ resolvedActionFeedbackContract.startsWith('angular-operate') || resolvedActionFeedbackContract.startsWith('angular-no-select-o')
+ ? resolvedActionFeedbackContract
+ : undefined
+ }
+ data-collector-operate-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-collector-operate-feedback-title={resolvedActionFeedbackContract === 'angular-operate-fail' ? 'common.notify.operate-fail' : undefined}
+ data-collector-operate-feedback-detail={resolvedActionFeedbackContract === 'angular-operate-fail' && actionMeta ? 'backend-message' : undefined}
+ />
+ ) : null}
<div
data-collector-table-shell="cold-dense-table"
- className="overflow-hidden rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_20px_56px_rgba(0,0,0,0.32)]"
+ data-collector-table-loading={tableLoading ? 'true' : 'false'}
+ data-collector-table-loading-contract="angular-load-collectors-table"
+ data-collector-table-loading-owner="angular-nz-table-loading"
+ data-collector-table-loading-scope="load-search-refresh-pagination-mutation"
+ data-collector-table-loading-source={tableLoading ? 'route-load-or-mutation' : 'idle'}
+ aria-busy={tableLoading ? 'true' : 'false'}
+ className="overflow-visible rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_20px_56px_rgba(0,0,0,0.32)]"
>
<table data-collector-manage-table="cold-collector-table" className="w-full table-fixed border-collapse text-left text-[12px] text-[#a9b0bb]">
<thead className="border-b border-[#252b34] bg-[#101217] text-[11px] font-semibold uppercase tracking-[0.12em] text-[#7e8494]">
<tr>
+ <th className="w-[44px] px-3 py-2.5">
+ <Checkbox
+ data-collector-select-all="cold-checkbox"
+ data-collector-select-all-contract="angular-header-includes-disabled-default"
+ data-collector-selectable-names={collectorNames.join(',')}
+ data-collector-immutable-names={immutableCollectorNames.join(',')}
+ aria-label={t('common.select')}
+ containerClassName="min-h-0"
+ checked={allSelected}
+ disabled={collectorNames.length === 0}
+ onChange={event => {
+ onSelectedCollectorsChange?.(event.target.checked ? collectorNames : []);
+ }}
+ />
+ </th>
<th className="w-[18%] px-3 py-2.5">{t('collector.name')}</th>
<th className="w-[11%] px-3 py-2.5">{t('collector.status')}</th>
<th className="w-[14%] px-3 py-2.5">{t('collector.mode')}</th>
@@ -179,8 +433,29 @@ export function CollectorManageSurface({
</tr>
</thead>
<tbody>
- {rows.length > 0 ? rows.map(row => (
- <tr key={row.key} data-collector-row={row.key} className="border-t border-[#252b34] bg-[#0b0c0e] transition hover:bg-[#111721]">
+ {rows.length > 0 ? rows.map(row => {
+ const rowActionMenuId = row.name;
+ const isRowActionMenuOpen = openRowActionMenuId === rowActionMenuId;
+
+ return (
+ <tr key={row.key} data-collector-row={row.key} className="border-t border-[#252b34] bg-[#0b0c0e] transition hover:bg-[#111721]">
+ <td className="px-3 py-2.5">
+ <Checkbox
+ aria-label={t('common.select')}
+ data-collector-row-select={row.name}
+ data-collector-row-select-contract={row.canMutate ? 'angular-selectable' : 'angular-disabled-visual-only'}
+ containerClassName="min-h-0"
+ checked={row.canMutate && resolvedSelectedCollectors.includes(row.name)}
+ disabled={!row.canMutate}
+ onChange={event => {
+ onSelectedCollectorsChange?.(
+ event.target.checked
+ ? [...resolvedSelectedCollectors, row.name]
+ : resolvedSelectedCollectors.filter(value => value !== row.name)
+ );
+ }}
+ />
+ </td>
<td className="px-3 py-2.5 font-semibold text-[#eef2f7]">
<div className="min-w-0">
<span className="inline-flex max-w-full items-center gap-1.5">
@@ -200,7 +475,10 @@ export function CollectorManageSurface({
</div>
</td>
<td className="px-3 py-2.5">
- <span className={`rounded-[3px] border px-2 py-0.5 text-[11px] font-semibold leading-4 ${statusClassName(row.statusLabel)}`}>
+ <span
+ data-collector-status-tone={row.statusTone}
+ className={`rounded-[3px] border px-2 py-0.5 text-[11px] font-semibold leading-4 ${statusClassName(row.statusTone)}`}
+ >
{row.statusLabel}
</span>
</td>
@@ -223,8 +501,12 @@ export function CollectorManageSurface({
variant="default"
className={coldIconButtonClassName}
title={t('collector.online')}
- onClick={() => onRowGoOnline(row.name)}
+ onClick={() => {
+ setOpenRowActionMenuId(null);
+ onRowGoOnline(row.name);
+ }}
disabled={!row.canMutate}
+ data-collector-online-one={row.name}
>
<Power className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sr-only">{t('collector.online')}</span>
@@ -235,35 +517,322 @@ export function CollectorManageSurface({
variant="default"
className={coldIconButtonClassName}
title={t('collector.offline')}
- onClick={() => onRowGoOffline(row.name)}
+ onClick={() => {
+ setOpenRowActionMenuId(null);
+ onRowGoOffline(row.name);
+ }}
disabled={!row.canMutate}
+ data-collector-offline-one={row.name}
>
<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sr-only">{t('collector.offline')}</span>
</Button>
)}
- <Button
- size="icon"
- variant="default"
- className={coldIconButtonClassName}
- title={t('collector.delete')}
- onClick={() => onRowDelete(row.name)}
- disabled={!row.canMutate}
+ <div
+ className={collectorActionMenuRootClassName}
+ data-collector-row-delete-menu={rowActionMenuId}
+ data-collector-row-delete-menu-contract="angular-ellipsis-dropdown-delete"
+ data-collector-row-delete-menu-layer="overlay-visible-above-panel"
+ data-collector-row-delete-menu-clearance="floating-overlay-no-panel-crop"
+ data-collector-row-delete-menu-open={isRowActionMenuOpen ? 'true' : 'false'}
>
- <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
- <span className="sr-only">{t('collector.delete')}</span>
- </Button>
+ <button
+ type="button"
+ aria-expanded={isRowActionMenuOpen}
+ aria-label={t('common.edit')}
+ title={t('common.edit')}
+ className={`${coldIconButtonClassName} inline-flex cursor-pointer list-none items-center justify-center [&::-webkit-details-marker]:hidden`}
+ onClick={() => {
+ setOpenRowActionMenuId(current => (current === rowActionMenuId ? null : rowActionMenuId));
+ }}
+ data-collector-row-delete-menu-trigger={rowActionMenuId}
+ >
+ <MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
+ <span className="sr-only">{t('common.edit')}</span>
+ </button>
+ <div
+ role="menu"
+ hidden={!isRowActionMenuOpen}
+ className={`${collectorActionMenuPanelBaseClassName} right-0`}
+ data-collector-row-delete-menu-panel={rowActionMenuId}
+ data-collector-row-delete-menu-layer-panel="overlay-visible-above-panel"
+ data-collector-row-delete-menu-clearance-panel="floating-overlay-no-panel-crop"
+ data-collector-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"
+ data-collector-row-delete-menu-panel-open={isRowActionMenuOpen ? 'true' : 'false'}
+ >
+ <HzTableRowActionButton
+ width="root-span"
+ intent="ghost"
+ onClick={() => {
+ setOpenRowActionMenuId(null);
+ onRowDelete(row.name);
+ }}
+ disabled={!row.canMutate}
+ data-collector-delete-one={row.name}
+ data-collector-delete-one-owner="hertzbeat-ui-table-row-action-button"
+ className="w-full text-[#fecaca] hover:text-white"
+ >
+ <Trash2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+ <span className="truncate">{t('collector.delete')}</span>
+ </HzTableRowActionButton>
+ </div>
+ </div>
</div>
</td>
</tr>
- )) : <EmptyTableRow colSpan={10} />}
+ );
+ }) : <EmptyTableRow colSpan={11} t={t} />}
</tbody>
</table>
+ <div
+ data-collector-pagination="cold-dense-pagination"
+ data-collector-pagination-owner="hertzbeat-ui-pagination-bar"
+ data-collector-pagination-contract="angular-search-pagination"
+ >
+ <HzPaginationBar
+ summary={paginationSummary}
+ pageSizeLabel={t('collector.pagination.page-size')}
+ pageSizeValue={String(currentPageSize)}
+ pageSizeOptions={pageSizeOptions.map(value => ({ value: String(value), label: String(value) }))}
+ pageJumpLabel={t('collector.pagination.page')}
+ pageJumpValue={String(currentPage)}
+ pageJumpMax={totalPages}
+ previousLabel={t('common.previous-page')}
+ nextLabel={t('common.next-page')}
+ previousDisabled={currentPageIndex <= 0}
+ nextDisabled={currentPage >= totalPages}
+ onPrevious={() => onPageIndexChange?.(Math.max(currentPageIndex - 1, 0))}
+ onNext={() => onPageIndexChange?.(Math.min(currentPageIndex + 1, totalPages - 1))}
+ onPageSizeChange={value => onPageSizeChange?.(Number.parseInt(value, 10))}
+ onPageJumpChange={handlePageJumpChange}
+ pageJumpInputProps={
+ {
+ 'data-collector-pagination-page-jump-owner': 'hertzbeat-ui-input'
+ } as React.ComponentProps<typeof HzPaginationBar>['pageJumpInputProps']
+ }
+ pageSizeSelectProps={
+ {
+ 'data-collector-pagination-page-size-owner': 'hertzbeat-ui-select'
+ } as React.ComponentProps<typeof HzPaginationBar>['pageSizeSelectProps']
+ }
+ className="border-x-0"
+ />
+ </div>
</div>
</section>
</div>
</div>
- </section>
- </div>
+ </section>
+ </div>
+ <HzConfirmDialog
+ open={Boolean(deleteTarget)}
+ tone="critical"
+ kicker={t('menu.advanced.collector')}
+ title={resolveCollectorConfirmTitle(deleteTarget, t)}
+ cancelLabel={t('common.button.cancel')}
+ confirmLabel={t('common.button.ok')}
+ confirmDisabled={mutationPending}
+ onCancel={onDeleteCancel ?? (() => {})}
+ onConfirm={onDeleteConfirm ?? (() => {})}
+ data-collector-delete-confirm="angular-modal-confirm"
+ data-collector-delete-confirm-owner="hertzbeat-ui-confirm-dialog"
+ data-collector-operate-confirm={targetAction === 'online' || targetAction === 'offline' ? 'angular-modal-confirm' : undefined}
+ data-collector-operate-confirm-owner="hertzbeat-ui-confirm-dialog"
+ confirmButtonProps={{
+ 'data-collector-delete-confirm-submit': 'angular-modal-confirm',
+ 'data-collector-operate-confirm-submit': targetAction === 'online' || targetAction === 'offline' ? 'angular-modal-confirm' : undefined
+ } as React.ComponentProps<typeof HzConfirmDialog>['confirmButtonProps']}
+ >
+ <div data-collector-delete-confirm-target={deleteTarget?.label ?? ''}>
+ {deleteTarget?.label ?? t('common.none')}
+ </div>
+ </HzConfirmDialog>
+ {deployOpen ? (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6"
+ data-collector-deploy-dialog="angular-generate-identity-modal"
+ data-collector-deploy-dialog-owner="collector-manage-surface"
+ data-collector-deploy-mask-closable="angular-mask-closable-false"
+ data-collector-deploy-mask-closable-owner="angular-nz-modal"
+ data-collector-deploy-close-reset="angular-close-clears-name"
+ data-collector-deploy-close-reset-owner="collector-route-controller"
+ data-collector-deploy-close-pending-result="angular-close-ignores-late-generate"
+ data-collector-deploy-close-pending-result-owner="collector-route-controller"
+ >
+ <section
+ className="grid max-h-[88vh] w-[min(92vw,920px)] overflow-hidden rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_28px_70px_rgba(0,0,0,0.48)] md:w-[45vw] md:max-w-[45vw]"
+ data-collector-deploy-width="angular-width-45-percent"
+ data-collector-deploy-width-owner="angular-nz-modal"
+ >
+ <header className="grid min-h-[56px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#252b34] bg-[#101217] px-5">
+ <div className="min-w-0">
+ <div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#7e8494]">{t('menu.advanced.collector')}</div>
+ <h2 className="truncate text-[18px] font-semibold text-[#eef2f7]">{t('collector.deploy')}</h2>
+ </div>
+ <button
+ type="button"
+ className="inline-flex h-8 w-8 items-center justify-center rounded-[3px] border border-[#2b3039] bg-[#101217] text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28]"
+ onClick={onDeployClose}
+ data-collector-deploy-close="angular-modal-cancel"
+ data-collector-deploy-close-reset="angular-close-clears-name"
+ data-collector-deploy-close-reset-owner="collector-route-controller"
+ aria-label={t('collector.deploy.close')}
+ >
+ <X className="h-4 w-4" aria-hidden="true" />
+ </button>
+ </header>
+ <div className="min-h-0 overflow-y-auto p-5">
+ <div
+ className="grid gap-2 md:grid-cols-[7fr_12fr] md:items-start"
+ data-collector-deploy-field="name"
+ data-collector-deploy-field-layout="angular-label-7-control-12"
+ >
+ <label
+ htmlFor="collector-deploy-name"
+ className="pt-2 text-[12px] font-semibold text-[#dbe4f0] md:text-right"
+ data-collector-deploy-label-span="7"
+ >
+ {t('collector.name')} <span className="text-[#f97316]">*</span>
+ </label>
+ <div className="min-w-0" data-collector-deploy-control-span="12">
+ <input
+ id="collector-deploy-name"
+ value={deployName ?? ''}
+ disabled={Boolean(resolvedDeployIdentity)}
+ onChange={event => onDeployNameChange?.(event.target.value)}
+ placeholder={t('collector.name.placeholder')}
+ className="h-8 w-full rounded-[3px] border border-[#2b3039] bg-[#101217] px-3 text-[12px] text-[#dbe4f0] outline-none transition placeholder:text-[#6f7788] focus:border-[#4e74f8] focus:ring-2 focus:ring-[#4e74f8]/10 disabled:opacity-60"
+ data-collector-deploy-name-input="angular-required-name"
+ data-collector-deploy-name-validation-raw="angular-required-before-trim"
+ data-collector-deploy-name-validation-raw-owner="collector-route-controller"
+ data-collector-deploy-name-validation-state={showDeployNameValidation ? 'dirty-invalid' : 'pristine'}
+ data-collector-deploy-name-lock={resolvedDeployIdentity ? 'angular-disable-after-identity' : 'editable-before-identity'}
+ data-collector-deploy-name-lock-owner="angular-ngmodel-input"
+ data-collector-deploy-name-disabled={resolvedDeployIdentity ? 'true' : 'false'}
+ />
+ {showDeployNameValidation ? (
+ <div
+ className="mt-1 text-[11px] font-medium text-[#fca5a5]"
+ data-collector-deploy-name-validation="validation.required"
+ data-collector-deploy-name-validation-contract="angular-submit-marks-required"
+ >
+ {t('validation.required')}
+ </div>
+ ) : null}
+ </div>
+ </div>
+ {deployError ? (
+ <div className="mt-4">
+ <HzInlineFeedback
+ tone="critical"
+ title={t('common.notify.apply-fail')}
+ meta={deployError}
+ variant="embedded"
+ data-collector-deploy-feedback="angular-apply-fail-notification"
+ data-collector-deploy-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-collector-deploy-feedback-title="common.notify.apply-fail"
+ data-collector-deploy-feedback-detail="backend-message"
+ />
+ </div>
+ ) : null}
+ {deployCopyMessage ? (
+ <div className="mt-4">
+ <HzInlineFeedback
+ tone="success"
+ title={deployCopyMessage}
+ variant="embedded"
+ data-collector-deploy-copy-feedback="angular-copy-success"
+ data-collector-deploy-copy-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-collector-deploy-copy-feedback-duration-ms="800"
+ />
+ </div>
+ ) : null}
+ {resolvedDeployIdentity ? (
+ <div className="mt-5 grid gap-4" data-collector-deploy-result="angular-identity-shells">
+ <div className="grid gap-2 text-center">
+ <div className="text-[14px] font-semibold text-[#eef2f7]">{t('collector.deploy.identity')}</div>
+ <div className="text-[12px] text-[#858d9a]">{t('collector.deploy.identity.tip')}</div>
+ <button
+ type="button"
+ className="mx-auto inline-flex max-w-full items-center gap-2 rounded-[3px] border border-[#553d1f] bg-[#241711] px-3 py-2 font-mono text-[12px] font-semibold text-[#fed7aa] hover:border-[#f97316]"
+ onClick={() => onDeployCopy?.(resolvedDeployIdentity)}
+ data-collector-deploy-copy-identity="angular-copy-identity"
+ title={t('common.button.copy.tip')}
+ >
+ <span className="truncate">{resolvedDeployIdentity}</span>
+ <Copy className="h-3.5 w-3.5" aria-hidden="true" />
+ <span>{t('common.copy.button')}</span>
+ </button>
+ </div>
+ <div className="grid gap-4 lg:grid-cols-2">
+ <HzCodeEditor
+ readOnly
+ language="shell"
+ value={deployDockerShell ?? ''}
+ title={t('collector.deploy.docker')}
+ meta={deployHost ?? 'MANAGER_HOST'}
+ minHeight="240px"
+ data-collector-deploy-docker-shell="angular-docker-shell"
+ data-collector-deploy-code-owner="hertzbeat-ui-code-editor"
+ />
+ <HzCodeEditor
+ readOnly
+ language="yaml"
+ value={deployPackageShell ?? ''}
+ title={t('collector.deploy.package')}
+ meta={
+ <a
+ href="https://github.com/apache/hertzbeat/releases"
+ target="_blank"
+ rel="noreferrer"
+ data-collector-deploy-package-link="angular-github-releases-link"
+ data-collector-deploy-package-link-owner="angular-nz-modal"
+ >
+ {t('collector.deploy.package.github')}
+ </a>
+ }
+ minHeight="260px"
+ data-collector-deploy-package-shell="angular-package-shell"
+ data-collector-deploy-code-owner="hertzbeat-ui-code-editor"
+ />
+ </div>
+ </div>
+ ) : null}
+ </div>
+ <footer className="flex justify-end gap-2 border-t border-[#252b34] bg-[#101217] px-5 py-3">
+ {resolvedDeployIdentity ? (
+ <HzButton
+ size="md"
+ intent="danger"
+ onClick={onDeployClose}
+ data-collector-deploy-result-close="angular-close-saved"
+ data-collector-deploy-result-close-contract="angular-danger-close-after-identity"
+ data-collector-deploy-result-close-owner="hertzbeat-ui-button"
+ data-collector-deploy-result-close-reset="angular-close-clears-name"
+ data-collector-deploy-result-close-reset-owner="collector-route-controller"
+ >
+ {t('collector.deploy.close')}
+ </HzButton>
+ ) : (
+ <Button
+ size="sm"
+ variant="default"
+ className={coldPrimaryButtonClassName}
+ onClick={onDeployGenerate}
+ disabled={Boolean(isDeployPending || resolvedDeployIdentity)}
+ data-collector-deploy-generate="angular-generate-identity"
+ data-collector-deploy-generate-validation="angular-click-before-required"
+ data-collector-deploy-ok-loading={isDeployPending ? 'true' : 'false'}
+ data-collector-deploy-ok-loading-owner="angular-nz-ok-loading"
+ aria-busy={isDeployPending ? 'true' : 'false'}
+ >
+ {isDeployPending ? t('common.loading') : t('collector.deploy.ok')}
+ </Button>
+ )}
+ </footer>
+ </section>
+ </div>
+ ) : null}
+ </>
);
}
diff --git a/web-next/components/pages/label-manage-surface.test.tsx b/web-next/components/pages/label-manage-surface.test.tsx
index 4a5b00d029..8665493263 100644
--- a/web-next/components/pages/label-manage-surface.test.tsx
+++ b/web-next/components/pages/label-manage-surface.test.tsx
@@ -4,10 +4,10 @@ import { resolve } from 'node:path';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { createTranslatorMock } from '../../test/i18n-test-helper';
+import { LabelManageSurface } from './label-manage-surface';
describe('label manage surface', () => {
- it('renders the OTLP cold-matte label console, dense table, and authoring dialog shell', async () => {
- const { LabelManageSurface } = await import('./label-manage-surface');
+ it('renders the OTLP cold-matte label console, Angular card grid, and authoring dialog shell', () => {
const t = createTranslatorMock({
locale: 'zh-CN',
overrides: {
@@ -15,11 +15,13 @@ describe('label manage surface', () => {
'common.refresh': '刷新',
'common.button.new': '新增',
'common.search': '搜索',
+ 'common.clear': '清除',
'common.copy.button': '复制',
'common.button.edit': '编辑',
'common.button.delete': '删除选中项',
'common.button.cancel': '取消',
'common.button.save': '保存',
+ 'common.none': '无标签预览',
'label.search': '搜索标签',
'label.new': '新增标签',
'label.edit': '编辑标签',
@@ -50,9 +52,17 @@ describe('label manage surface', () => {
id: 2,
name: 'source',
tagValue: 'system',
- description: 'preset label',
+ description: '',
type: 2,
gmtUpdate: '2026-04-09T10:00:00Z'
+ },
+ {
+ id: 3,
+ name: 'legacy',
+ tagValue: 'custom',
+ description: 'unknown type',
+ type: 99,
+ gmtUpdate: '2026-04-08T10:00:00Z'
}
],
totalElements: 2,
@@ -69,14 +79,21 @@ describe('label manage surface', () => {
type: 1
}}
isManageModalAdd
+ deleteTarget={null}
+ isDeletePending={false}
+ isSavePending
+ actionMessage="新增成功"
formatTime={() => '2026-04-10 18:00:00'}
onSearchChange={() => {}}
onSearch={() => {}}
+ onSearchClear={() => {}}
onRefresh={() => {}}
onNew={() => {}}
onCopy={() => {}}
onEdit={() => {}}
- onDelete={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
onDraftChange={() => {}}
onCloseDialog={() => {}}
onSaveDialog={() => {}}
@@ -88,32 +105,144 @@ describe('label manage surface', () => {
expect(html).toContain('data-label-header="cold-compact-header"');
expect(html).toContain('data-label-command-row="standard-equal-buttons"');
expect(html).toContain('data-label-admin-layout="full-width-admin-list"');
+ expect(html).toContain('data-label-save-feedback-contract="angular-new-edit-notify"');
+ expect(html).toContain('data-label-save-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-save-failure-contract="angular-new-edit-fail-notification"');
+ expect(html).toContain('data-label-save-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-label-load-failure-owner="label-route-controller"');
+ expect(html).toContain('data-label-load-failure="none"');
+ expect(html).toContain('data-label-copy-feedback-contract="angular-copy-notify"');
+ expect(html).toContain('data-label-copy-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-label-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-label-delete-confirm-state-contract="angular-modal-confirm-state"');
+ expect(html).toContain('data-label-delete-confirm-state-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-label-delete-confirm-state="closed"');
+ expect(html).toContain('data-label-delete-confirm-state-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-label-delete-confirm-pending="false"');
+ expect(html).toContain('data-label-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-label-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-delete-failure-contract="angular-delete-fail-notification"');
+ expect(html).toContain('data-label-delete-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-delete-query-contract="angular-repeated-ids-query"');
+ expect(html).toContain('data-label-delete-query-owner="route-mutation-contract"');
+ expect(html).toContain('data-label-card-loading-contract="angular-table-loading"');
+ expect(html).toContain('data-label-card-loading-owner="label-route-controller"');
+ expect(html).toContain('data-label-card-loading="false"');
+ expect(html).toContain('data-label-save-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-label-save-loading-contract-owner="angular-nz-ok-loading"');
+ expect(html).toContain('data-label-dialog-mask-closable-contract="angular-mask-closable-false"');
+ expect(html).toContain('data-label-dialog-mask-closable-owner="angular-nz-modal"');
+ expect(html).toContain('data-label-dialog-width-contract="angular-width-30-percent"');
+ expect(html).toContain('data-label-dialog-width-owner="angular-nz-modal"');
+ expect(html).toContain('data-label-dialog-field-layout-contract="angular-label-7-control-12"');
+ expect(html).toContain('data-label-dialog-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-label-dialog-preview-visibility-contract="angular-name-defined-preview"');
+ expect(html).toContain('data-label-dialog-preview-visibility-owner="route-form-state"');
+ expect(html).toContain('data-label-dialog-preview-frame-contract="angular-inline-tag-no-extra-frame"');
+ expect(html).toContain('data-label-dialog-preview-frame-owner="route-form-field-grid"');
+ expect(html).toContain('data-label-dialog-preview-chrome-contract="angular-tag-only-no-extra-frame"');
+ expect(html).toContain('data-label-dialog-preview-chrome-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('data-label-edit-reference-contract="angular-edit-direct-reference"');
+ expect(html).toContain('data-label-edit-reference-owner="route-form-state"');
+ expect(html).toContain('data-label-name-validation-contract="angular-required-before-submit"');
+ expect(html).toContain('data-label-name-validation-trigger-contract="angular-ok-marks-dirty"');
+ expect(html).toContain('data-label-name-validation-trigger-owner="route-form-contract"');
+ expect(html).toContain('data-label-name-validation-raw-contract="angular-required-before-trim"');
+ expect(html).toContain('data-label-name-validation-raw-owner="route-form-contract"');
+ expect(html).toContain('data-label-query-contract="angular-load-all-labels"');
+ expect(html).toContain('data-label-query-owner="label-query-state"');
+ expect(html).toContain('data-label-query-param-order-contract="angular-page-index-size-type-search"');
+ expect(html).toContain('data-label-query-param-order-owner="label-query-state"');
+ expect(html).toContain('data-label-card-grid-contract="angular-card-grid"');
+ expect(html).toContain('data-label-card-grid-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('aria-busy="false"');
+ expect(html).toContain('data-label-monitor-handoff-contract="angular-routerlink-monitors-labels"');
+ expect(html).toContain('data-label-monitor-handoff-owner="next-monitor-query-link"');
+ expect(html).toContain('data-hz-ui="label-tag"');
+ expect(html).toContain('data-hz-label-tag-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('data-hz-label-color-token="geekblue"');
+ expect(html).toContain('data-label-color-token="geekblue"');
+ expect(html).toContain('data-label-color-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('data-label-dialog-preview-color="angular-render-label-color"');
+ expect(html).toContain('data-label-dialog-preview-chrome="angular-nz-tag-inline"');
+ expect(html).toContain('data-label-dialog-preview-chrome-mode="angular-tag-only-no-extra-frame"');
+ expect(html).toContain('data-overlay-dialog-mask-closable="false"');
+ expect(html).toContain('data-label-dialog-mask-closable="false"');
+ expect(html).toContain('data-label-dialog-width="angular-width-30-percent"');
+ expect(html).toContain('data-label-dialog-field-layout="angular-label-7-control-12"');
+ expect(html).toContain('data-label-dialog-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-label-dialog-field-layout-row="angular-label-7-control-12"');
+ expect(html).toContain('data-label-dialog-label-span="7"');
+ expect(html).toContain('data-label-dialog-control-span="12"');
+ expect(html).toContain('sm:grid-cols-[minmax(96px,7fr)_minmax(0,12fr)]');
+ expect(html).toContain('lg:max-w-[30vw]');
+ expect(html).toContain('data-label-save-ok-loading="true"');
+ expect(html).toContain('data-label-save-ok-loading-owner="angular-nz-ok-loading"');
+ expect(html).toContain('aria-busy="true"');
expect(html).toContain('data-label-toolbar="cold-table-toolbar"');
expect(html).toContain('data-label-search-owner="shared-search-row"');
+ expect(html).toContain('data-label-search-submit="angular-enter-and-clear"');
+ expect(html).toContain('data-label-search-submit-owner="cold-search-row"');
+ expect(html).toContain('data-label-action-feedback="angular-action-success"');
+ expect(html).toContain('data-label-action-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-save-feedback="angular-save-success"');
+ expect(html).toContain('data-label-save-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-save-trim-contract="angular-trim-before-save"');
+ expect(html).toContain('data-label-save-trim-owner="label-manage-controller"');
+ expect(html).toContain('data-label-optional-save-payload-contract="angular-preserve-undefined-optional-fields"');
+ expect(html).toContain('data-label-optional-save-payload-owner="label-manage-controller"');
+ expect(html).toContain('data-label-type-save-payload-contract="angular-preserve-implicit-type"');
+ expect(html).toContain('data-label-type-save-payload-owner="label-manage-controller"');
+ expect(html).toContain('data-label-display-format-contract="angular-format-label-name-raw-tag-value"');
+ expect(html).toContain('data-label-display-format-owner="label-view-model"');
+ expect(html).toContain('data-label-description-display-contract="angular-truthy-description-raw"');
+ expect(html).toContain('data-label-description-display-owner="label-manage-surface"');
+ expect(html).toContain('data-hz-ui="inline-feedback"');
+ expect(html).toContain('data-hz-feedback-tone="success"');
+ expect(html).toContain('新增成功');
expect(html).toContain('data-cold-search-row-owner="cold-search-row"');
expect(html).toContain('data-cold-search-input="fixed-width-direct"');
expect(html).toContain('data-cold-search-control="direct-input"');
expect(html).toContain('data-cold-search-chrome="no-extra-input-shell"');
expect(html).toContain('data-cold-search-action="submit"');
- expect(html).toContain('data-label-table-shell="cold-dense-table"');
- expect(html).toContain('data-label-table="cold-label-table"');
+ expect(html).toContain('data-cold-search-action="clear"');
+ expect(html).toContain('data-label-card-grid="angular-card-grid"');
+ expect(html).toContain('data-label-card-grid-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('data-label-card-column-contract="nz-xs-12-sm-8-md-6-lg-4"');
expect(html).toContain('data-label-row="1"');
- expect(html).toContain('data-label-row-actions="cold-icon-actions"');
+ expect(html).toContain('data-label-card-shell="angular-card"');
+ expect(html).toContain('data-label-card-size="small"');
+ expect(html).toContain('data-label-card-content="angular-label-content"');
+ expect(html).toContain('data-label-card-description="angular-description"');
+ expect(html).toContain('data-label-card-description-display="angular-truthy-description-raw"');
+ expect(html).toContain('data-label-card-description-display-owner="label-manage-surface"');
+ expect(html).toContain('data-label-card-actions="angular-card-actions"');
+ expect(html).toContain('data-label-monitor-handoff="angular-routerlink-monitors-labels"');
+ expect(html).toContain('data-label-monitor-handoff-query="team:ops"');
+ expect(html).toContain('data-label-monitor-handoff-owner="next-monitor-query-link"');
+ expect(html).toContain('data-label-row-actions="angular-card-actions-contextual"');
+ expect(html).toContain('data-label-row-actions-owner="hertzbeat-ui-action-group"');
+ expect(html).toContain('data-label-row-action="copy"');
+ expect(html).toContain('data-label-row-action="edit"');
+ expect(html).toContain('data-label-row-action="delete"');
+ expect(html).toContain('data-label-row-action-owner="row-contextual-icon-button"');
+ expect(html).toContain('data-label-row-action-label="team:ops"');
expect(html).not.toContain('data-label-summary-rail=');
expect(html).not.toContain('data-cold-search-input-shell');
expect(html).toContain('标签管理');
expect(html).toContain('新增');
expect(html).toContain('搜索标签');
expect(html).toContain('搜索');
- expect(html).toContain('aria-label="复制"');
- expect(html).toContain('aria-label="编辑"');
- expect(html).toContain('aria-label="删除选中项"');
+ expect(html).toContain('aria-label="复制标签 team:ops"');
+ expect(html).toContain('aria-label="编辑标签 team:ops"');
+ expect(html).toContain('aria-label="删除标签 team:ops"');
+ expect(html).toContain('href="/monitors?labels=team%3Aops"');
expect(html).toContain('team:ops');
expect(html).toContain('source:system');
expect(html).toContain('ops team');
- expect(html).toContain('preset label');
- expect(html).toContain('用户标签');
- expect(html).toContain('预置标签');
+ expect(html).not.toContain('用户标签');
expect(html).toContain('data-overlay-dialog="true"');
expect(html).toContain('新增标签');
expect(html).toContain('标签名');
@@ -122,13 +251,11 @@ describe('label manage surface', () => {
expect(html).toContain('效果');
expect(html).toContain('取消');
expect(html).toContain('保存');
- expect(html).not.toContain('data-label-manage-route="angular-label-cards"');
- expect(html).not.toContain('data-label-card-shell="angular-card"');
+ expect(html).not.toContain('data-label-table="cold-label-table"');
expect(html).not.toContain('setting.labels.subtitle');
- });
+ }, 30000);
- it('renders a cold table empty state with Chinese fallback copy when no labels exist', async () => {
- const { LabelManageSurface } = await import('./label-manage-surface');
+ it('renders a cold card empty state with Chinese fallback copy when no labels exist', () => {
const t = createTranslatorMock({
locale: 'zh-CN',
overrides: {
@@ -136,6 +263,7 @@ describe('label manage surface', () => {
'common.refresh': '刷新',
'common.button.new': '新增',
'common.search': '搜索',
+ 'common.clear': '清除',
'label.search': '搜索标签'
}
});
@@ -154,21 +282,26 @@ describe('label manage surface', () => {
search=""
draftLabel={null}
isManageModalAdd={false}
+ deleteTarget={null}
+ isDeletePending={false}
formatTime={() => '-'}
onSearchChange={() => {}}
onSearch={() => {}}
+ onSearchClear={() => {}}
onRefresh={() => {}}
onNew={() => {}}
onCopy={() => {}}
onEdit={() => {}}
- onDelete={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
onDraftChange={() => {}}
onCloseDialog={() => {}}
onSaveDialog={() => {}}
/>
);
- expect(html).toContain('data-label-empty-state="cold-table-empty"');
+ expect(html).toContain('data-label-empty-state="cold-card-empty"');
expect(html).toContain('data-label-empty-icon="cold-empty-box"');
expect(html).toContain('暂无标签');
expect(html).toContain('请先新增标签。');
@@ -176,22 +309,724 @@ describe('label manage surface', () => {
expect(html).not.toContain('setting.labels.empty.copy');
});
+ it('keeps Angular formatLabelName raw tag value spacing for display, copy, and monitor handoff', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ id: 9,
+ name: 'team',
+ tagValue: ' ops ',
+ description: '',
+ type: 1
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={null}
+ isManageModalAdd={false}
+ deleteTarget={null}
+ isDeletePending={false}
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-display-format-contract="angular-format-label-name-raw-tag-value"');
+ expect(html).toContain('data-label-monitor-handoff-query="team: ops "');
+ expect(html).toContain('data-label-row-action-label="team: ops "');
+ expect(html).toContain('href="/monitors?labels=team%3A%20ops%20"');
+ expect(html).toContain('team: ops ');
+ expect(html).not.toContain('team:ops');
+ });
+
+ it('renders whitespace-only descriptions because Angular only checks description truthiness', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ id: 10,
+ name: 'team',
+ tagValue: 'ops',
+ description: ' ',
+ type: 1
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={null}
+ isManageModalAdd={false}
+ deleteTarget={null}
+ isDeletePending={false}
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-description-display-contract="angular-truthy-description-raw"');
+ expect(html).toContain('data-label-description-display-owner="label-manage-surface"');
+ expect(html).toContain('data-label-card-description="angular-description"');
+ expect(html).toContain('data-label-card-description-display="angular-truthy-description-raw"');
+ expect(html).toContain('data-label-card-description-display-owner="label-manage-surface"');
+ expect(html).toContain('title=" "');
+ });
+
+ it('keeps the new-label dialog preview hidden until the Angular name-defined path runs', () => {
+ const t = createTranslatorMock({
+ locale: 'zh-CN',
+ overrides: {
+ 'menu.advanced.labels': '标签管理',
+ 'common.refresh': '刷新',
+ 'common.button.new': '新增',
+ 'common.search': '搜索',
+ 'common.button.cancel': '取消',
+ 'common.button.save': '保存',
+ 'common.none': '无标签预览',
+ 'label.search': '搜索标签',
+ 'label.new': '新增标签',
+ 'label.edit': '编辑标签',
+ 'label.name': '标签名',
+ 'label.value': '标签值',
+ 'label.description': '标签描述',
+ 'label.display': '效果',
+ 'setting.labels.empty.title': '暂无标签',
+ 'setting.labels.empty.copy': '请先新增标签。'
+ }
+ });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={{
+ id: 0,
+ name: undefined as unknown as string,
+ tagValue: undefined,
+ description: undefined,
+ type: 1
+ }}
+ isManageModalAdd
+ deleteTarget={null}
+ isDeletePending={false}
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-dialog-preview-visibility="hidden"');
+ expect(html).toContain('data-label-dialog-preview-visibility-owner="route-form-state"');
+ expect(html).not.toContain('data-label-dialog-preview="cold-preview"');
+ expect(html).toContain('data-label-dialog-name-input="angular-required-name"');
+ expect(html).toContain('data-label-dialog-name-state="pristine"');
+ expect(html).toContain('data-label-save-submit-state="pristine"');
+ expect(html).not.toContain('data-label-dialog-name-validation="validation.required"');
+ expect(html).toContain('data-label-save-submit="angular-required-before-submit"');
+ expect(html).toContain('data-label-name-validation-trigger="angular-ok-marks-dirty"');
+ expect(html).toContain('data-label-name-validation-trigger-owner="route-form-contract"');
+ expect(html).not.toContain('disabled=""');
+ expect(html).toContain('data-label-save-trim="angular-trim-before-save"');
+ expect(html).toContain('data-label-save-trim-owner="label-manage-controller"');
+ expect(html).toContain('data-label-optional-save-payload-contract="angular-preserve-undefined-optional-fields"');
+ expect(html).toContain('data-label-optional-save-payload-owner="label-manage-controller"');
+ expect(html).toContain('data-label-type-save-payload-contract="angular-preserve-implicit-type"');
+ expect(html).toContain('data-label-type-save-payload-owner="label-manage-controller"');
+ expect(html).not.toContain('无标签预览');
+ });
+
+ it('shows the Angular display preview row once the label name is defined', () => {
+ const t = createTranslatorMock({
+ locale: 'zh-CN',
+ overrides: {
+ 'menu.advanced.labels': '标签管理',
+ 'common.refresh': '刷新',
+ 'common.button.new': '新增',
+ 'common.search': '搜索',
+ 'common.button.cancel': '取消',
+ 'common.button.save': '保存',
+ 'common.none': '无标签预览',
+ 'label.search': '搜索标签',
+ 'label.new': '新增标签',
+ 'label.edit': '编辑标签',
+ 'label.name': '标签名',
+ 'label.value': '标签值',
+ 'label.description': '标签描述',
+ 'label.display': '效果',
+ 'setting.labels.empty.title': '暂无标签',
+ 'setting.labels.empty.copy': '请先新增标签。'
+ }
+ });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ draftLabel={{ id: 0, name: 'team', tagValue: '', description: '', type: 1 }}
+ isManageModalAdd
+ deleteTarget={null}
+ isDeletePending={false}
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-dialog-preview="cold-preview"');
+ expect(html).toContain('data-label-dialog-preview-visibility="visible"');
+ expect(html).toContain('data-label-dialog-preview-frame="angular-inline-tag-no-extra-frame"');
+ expect(html).toContain('data-label-dialog-preview-frame-owner="route-form-field-grid"');
+ expect(html).toContain('data-label-dialog-preview-chrome="angular-nz-tag-inline"');
+ expect(html).toContain('data-label-dialog-preview-chrome-mode="angular-tag-only-no-extra-frame"');
+ expect(html).toContain('data-label-dialog-preview-chrome-owner="hertzbeat-ui-label-tag"');
+ expect(html).toContain('data-label-dialog-field="display"');
+ expect(html).toContain('data-label-dialog-label-span="7"');
+ expect(html).toContain('data-label-dialog-control-span="12"');
+ expect(html).not.toContain('data-label-dialog-preview="cold-preview" data-label-dialog-preview-visibility="visible" data-label-dialog-preview-visibility-owner="route-form-state" data-label-dialog-field="display" data-label-dialog-field-layout-row="angular-label-7-control-12" class="grid gap-2 text-[12px] font-semibold text-[#a9b0bb] sm:grid-cols-[minmax(96px,7fr)_minmax(0,12fr)] sm:items-start rounded-[4px] border');
+ expect(html).toContain('team');
+ });
+
+ it('marks the missing label name dirty only after the Angular OK-submit validation path runs', () => {
+ const t = createTranslatorMock({
+ locale: 'zh-CN',
+ overrides: {
+ 'menu.advanced.labels': '标签管理',
+ 'common.refresh': '刷新',
+ 'common.button.new': '新增',
+ 'common.search': '搜索',
+ 'common.button.cancel': '取消',
+ 'common.button.save': '保存',
+ 'common.none': '无标签预览',
+ 'validation.required': '必填项',
+ 'label.search': '搜索标签',
+ 'label.new': '新增标签',
+ 'label.name': '标签名',
+ 'label.value': '标签值',
+ 'label.description': '标签描述',
+ 'label.display': '效果',
+ 'setting.labels.empty.title': '暂无标签',
+ 'setting.labels.empty.copy': '请先新增标签。'
+ }
+ });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ draftLabel={{ id: 0, name: '', tagValue: '', description: '', type: 1 }}
+ isManageModalAdd
+ isNameValidationVisible
+ deleteTarget={null}
+ isDeletePending={false}
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-dialog-name-state="dirty-invalid"');
+ expect(html).toContain('data-label-save-submit-state="dirty-invalid"');
+ expect(html).toContain('data-label-dialog-name-validation="validation.required"');
+ expect(html).toContain('必填项');
+ expect(html).not.toContain('disabled=""');
+ });
+
+ it('does not mark a whitespace-only label name invalid before Angular trim-before-save runs', () => {
+ const t = createTranslatorMock({
+ locale: 'zh-CN',
+ overrides: {
+ 'menu.advanced.labels': '标签管理',
+ 'common.refresh': '刷新',
+ 'common.button.new': '新增',
+ 'common.search': '搜索',
+ 'common.button.cancel': '取消',
+ 'common.button.save': '保存',
+ 'common.none': '无标签预览',
+ 'validation.required': '必填项',
+ 'label.search': '搜索标签',
+ 'label.new': '新增标签',
+ 'label.name': '标签名',
+ 'label.value': '标签值',
+ 'label.description': '标签描述',
+ 'label.display': '效果',
+ 'setting.labels.empty.title': '暂无标签',
+ 'setting.labels.empty.copy': '请先新增标签。'
+ }
+ });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ draftLabel={{ id: 0, name: ' ', tagValue: undefined, description: undefined, type: 1 } as any}
+ isManageModalAdd
+ isNameValidationVisible
+ deleteTarget={null}
+ isDeletePending={false}
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-name-validation-raw-contract="angular-required-before-trim"');
+ expect(html).toContain('data-label-name-validation-raw-owner="route-form-contract"');
+ expect(html).toContain('data-label-dialog-name-state="pristine"');
+ expect(html).toContain('data-label-save-submit-state="pristine"');
+ expect(html).not.toContain('data-label-dialog-name-validation="validation.required"');
+ expect(html).not.toContain('必填项');
+ });
+
+ it('renders the shared Angular-style delete confirmation dialog when a label is targeted', () => {
+ const t = createTranslatorMock({
+ locale: 'zh-CN',
+ overrides: {
+ 'menu.advanced.labels': '标签管理',
+ 'common.confirm.delete': '请确认是否删除!',
+ 'common.button.cancel': '取消',
+ 'common.button.ok': '确定',
+ 'common.refresh': '刷新',
+ 'common.button.new': '新增',
+ 'common.search': '搜索',
+ 'label.search': '搜索标签',
+ 'setting.labels.empty.title': '暂无标签',
+ 'setting.labels.empty.copy': '请先新增标签。'
+ }
+ });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={null}
+ isManageModalAdd={false}
+ deleteTarget={{
+ id: 7,
+ name: 'team',
+ tagValue: 'ops',
+ description: '',
+ type: 1
+ }}
+ isDeletePending
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-hz-ui="confirm-dialog"');
+ expect(html).toContain('data-hz-confirm-tone="critical"');
+ expect(html).toContain('data-label-delete-confirm="angular-modal-confirm"');
+ expect(html).toContain('data-label-delete-confirm-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-label-delete-confirm-state="open"');
+ expect(html).toContain('data-label-delete-confirm-pending="true"');
+ expect(html).toContain('data-label-delete-confirm-state-target="team:ops"');
+ expect(html).toContain('data-label-delete-confirm-submit="angular-modal-confirm"');
+ expect(html).toContain('data-label-delete-confirm-close="angular-close-before-delete-result"');
+ expect(html).toContain('data-label-delete-confirm-close-owner="route-state-contract"');
+ expect(html).toContain('data-label-delete-confirm-target="team:ops"');
+ expect(html).toContain('请确认是否删除!');
+ expect(html).toContain('确定');
+ });
+
+ it('renders Angular label delete failure title with backend detail', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={null}
+ isManageModalAdd={false}
+ deleteTarget={null}
+ actionError="删除失败!"
+ actionMeta="backend refused label delete"
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-delete-feedback="angular-delete-fail"');
+ expect(html).toContain('data-label-delete-feedback-title="common.notify.delete-fail"');
+ expect(html).toContain('data-label-delete-feedback-detail="backend-message"');
+ expect(html).toContain('删除失败!');
+ expect(html).toContain('backend refused label delete');
+ });
+
+ it('renders Angular label save failure title with backend detail', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={null}
+ isManageModalAdd={false}
+ deleteTarget={null}
+ actionError="修改失败!"
+ actionMeta="backend refused label edit"
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-save-failure="angular-save-fail"');
+ expect(html).toContain('data-label-save-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-label-save-feedback-title="common.notify.edit-fail"');
+ expect(html).toContain('data-label-save-feedback-detail="backend-message"');
+ expect(html).toContain('修改失败!');
+ expect(html).toContain('backend refused label edit');
+ });
+
+ it('keeps the Angular label shell visible without action feedback on load failure', () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <LabelManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ draftLabel={null}
+ isManageModalAdd={false}
+ deleteTarget={null}
+ loadError="backend refused label load"
+ formatTime={() => '-'}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onSearchClear={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onCopy={() => {}}
+ onEdit={() => {}}
+ onDeleteRequest={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-label-load-failure="angular-console-only-shell"');
+ expect(html).toContain('data-label-load-failure-owner="label-route-controller"');
+ expect(html).toContain('data-label-card-loading="false"');
+ expect(html).not.toContain('backend refused label load');
+ expect(html).not.toContain('data-label-action-feedback="angular-action-error"');
+ });
+
it('keeps the labels page on the cold visual owner instead of WorkbenchPage or alert primitives', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/label-manage-surface.tsx'), 'utf8');
expect(source).toContain('coldOpsCatalogVisual');
expect(source).toContain("from '../ui/search-row'");
+ expect(source).toContain("from '@hertzbeat/ui'");
+ expect(source).toContain('HzInlineFeedback');
+ expect(source).toContain('HzConfirmDialog');
+ expect(source).toContain('HzLabelTag');
+ expect(source).toContain('renderAngularLabelColor');
+ expect(source).toContain('data-label-color-token');
+ expect(source).toContain('data-label-color-owner="hertzbeat-ui-label-tag"');
+ expect(source).toContain('data-label-dialog-preview-color="angular-render-label-color"');
+ expect(source).toContain('maskClosable={false}');
+ expect(source).toContain('data-label-dialog-mask-closable');
+ expect(source).toContain('data-label-dialog-width');
+ expect(source).toContain('data-label-dialog-field-layout');
+ expect(source).toContain('lg:max-w-[30vw]');
+ expect(source).toContain('data-label-delete-confirm="angular-modal-confirm"');
+ expect(source).toContain('data-label-delete-confirm-owner="hertzbeat-ui-confirm-dialog"');
+ expect(source).toContain("data-label-delete-confirm-state={deleteTarget ? 'open' : 'closed'}");
+ expect(source).toContain('data-label-delete-confirm-state-owner="hertzbeat-ui-confirm-dialog"');
+ expect(source).toContain("data-label-delete-confirm-pending={isDeletePending ? 'true' : 'false'}");
+ expect(source).toContain('data-label-delete-confirm-submit');
+ expect(source).toContain('data-label-delete-confirm-close-contract="angular-close-before-delete-result"');
+ expect(source).toContain('data-label-delete-confirm-close-owner="route-state-contract"');
+ expect(source).toContain("'data-label-delete-confirm-close': 'angular-close-before-delete-result'");
+ expect(source).toContain('data-label-delete-confirm-target');
+ expect(source).toContain("data-label-action-feedback={actionError ? 'angular-action-error' : 'angular-action-success'}");
+ expect(source).toContain('data-label-action-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain("data-label-save-feedback={actionError ? 'angular-save-error' : 'angular-save-success'}");
+ expect(source).toContain('data-label-save-feedback-owner="hertzbeat-ui-inline-feedback"');
expect(source).toContain('inputWidthClassName="w-[360px]"');
expect(source).toContain('data-label-manage-surface="otlp-cold-label-console"');
expect(source).toContain('data-label-manage-style-baseline={coldLabelVisual.canvasName}');
expect(source).toContain('data-label-header="cold-compact-header"');
expect(source).toContain('data-label-command-row="standard-equal-buttons"');
expect(source).toContain('data-label-admin-layout="full-width-admin-list"');
+ expect(source).toContain('data-label-save-feedback-contract="angular-new-edit-notify"');
+ expect(source).toContain('data-label-save-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-label-optional-save-payload-contract="angular-preserve-undefined-optional-fields"');
+ expect(source).toContain('data-label-optional-save-payload-owner="label-manage-controller"');
+ expect(source).toContain('data-label-type-save-payload-contract="angular-preserve-implicit-type"');
+ expect(source).toContain('data-label-type-save-payload-owner="label-manage-controller"');
+ expect(source).toContain('data-label-display-format-contract="angular-format-label-name-raw-tag-value"');
+ expect(source).toContain('data-label-display-format-owner="label-view-model"');
+ expect(source).toContain('data-label-description-display-contract="angular-truthy-description-raw"');
+ expect(source).toContain('data-label-description-display-owner="label-manage-surface"');
+ expect(source).toContain('data-label-save-failure-contract="angular-new-edit-fail-notification"');
+ expect(source).toContain('data-label-save-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-label-load-failure-contract="angular-console-only-shell"');
+ expect(source).toContain('data-label-load-failure-owner="label-route-controller"');
+ expect(source).toContain("data-label-load-failure={loadError ? 'angular-console-only-shell' : 'none'}");
+ expect(source).toContain("actionError === t('common.notify.new-fail')");
+ expect(source).toContain("actionError === t('common.notify.edit-fail')");
+ expect(source).toContain("data-label-save-failure={saveFailureKey ? 'angular-save-fail' : undefined}");
+ expect(source).toContain('data-label-save-feedback-title={saveFailureKey}');
+ expect(source).toContain("data-label-save-feedback-detail={saveFailureKey && actionMeta ? 'backend-message' : undefined}");
+ expect(source).toContain('data-label-copy-feedback-contract="angular-copy-notify"');
+ expect(source).toContain('data-label-copy-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-label-delete-confirm-contract="angular-modal-confirm"');
+ expect(source).toContain('data-label-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(source).toContain('data-label-delete-confirm-state-contract="angular-modal-confirm-state"');
+ expect(source).toContain('data-label-delete-confirm-state-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(source).toContain('data-label-delete-confirm-close-contract="angular-close-before-delete-result"');
+ expect(source).toContain('data-label-delete-confirm-close-owner="route-state-contract"');
+ expect(source).toContain('data-label-delete-feedback-contract="angular-delete-notify"');
+ expect(source).toContain('data-label-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-label-delete-failure-contract="angular-delete-fail-notification"');
+ expect(source).toContain('data-label-delete-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-label-delete-feedback-title={isDeleteFailureFeedback ?');
+ expect(source).toContain("data-label-delete-feedback-detail={isDeleteFailureFeedback && actionMeta ? 'backend-message' : undefined}");
+ expect(source).toContain('data-label-delete-query-contract="angular-repeated-ids-query"');
+ expect(source).toContain('data-label-delete-query-owner="route-mutation-contract"');
+ expect(source).toContain('data-label-card-loading-contract="angular-table-loading"');
+ expect(source).toContain('data-label-card-loading-owner="label-route-controller"');
+ expect(source).toContain("data-label-card-loading={isCardGridLoading ? 'true' : 'false'}");
+ expect(source).toContain("aria-busy={isCardGridLoading ? 'true' : 'false'}");
+ expect(source).toContain('data-label-save-loading-contract="angular-nz-ok-loading"');
+ expect(source).toContain('data-label-save-loading-contract-owner="angular-nz-ok-loading"');
+ expect(source).toContain('data-label-dialog-mask-closable-contract="angular-mask-closable-false"');
+ expect(source).toContain('data-label-dialog-width-contract="angular-width-30-percent"');
+ expect(source).toContain('data-label-dialog-field-layout-contract="angular-label-7-control-12"');
+ expect(source).toContain('data-label-dialog-field-layout-owner="route-form-field-grid"');
+ expect(source).toContain('data-label-dialog-preview-visibility-contract="angular-name-defined-preview"');
+ expect(source).toContain('data-label-dialog-preview-visibility-owner="route-form-state"');
+ expect(source).toContain('data-label-dialog-preview-frame-contract="angular-inline-tag-no-extra-frame"');
+ expect(source).toContain('data-label-dialog-preview-frame-owner="route-form-field-grid"');
+ expect(source).toContain('data-label-dialog-preview-chrome-contract="angular-tag-only-no-extra-frame"');
+ expect(source).toContain('data-label-dialog-preview-chrome-owner="hertzbeat-ui-label-tag"');
+ expect(source).toContain('data-label-edit-reference-contract="angular-edit-direct-reference"');
+ expect(source).toContain('data-label-edit-reference-owner="route-form-state"');
+ expect(source).toContain('data-label-name-validation-contract="angular-required-before-submit"');
+ expect(source).toContain('data-label-name-validation-trigger-contract="angular-ok-marks-dirty"');
+ expect(source).toContain('data-label-name-validation-trigger-owner="route-form-contract"');
+ expect(source).toContain('data-label-name-validation-raw-contract="angular-required-before-trim"');
+ expect(source).toContain('data-label-name-validation-raw-owner="route-form-contract"');
+ expect(source).toContain('data-label-query-contract="angular-load-all-labels"');
+ expect(source).toContain('data-label-query-owner="label-query-state"');
+ expect(source).toContain('data-label-query-param-order-contract="angular-page-index-size-type-search"');
+ expect(source).toContain('data-label-query-param-order-owner="label-query-state"');
+ expect(source).toContain('data-label-card-grid-contract="angular-card-grid"');
+ expect(source).toContain('data-label-card-grid-owner="hertzbeat-ui-label-tag"');
+ expect(source).toContain('data-label-monitor-handoff-contract="angular-routerlink-monitors-labels"');
+ expect(source).toContain('data-label-monitor-handoff-owner="next-monitor-query-link"');
+ expect(source).toContain('data-label-dialog-name-input="angular-required-name"');
+ expect(source).toContain('data-label-dialog-field-layout-row="angular-label-7-control-12"');
+ expect(source).toContain('data-label-dialog-label-span="7"');
+ expect(source).toContain('data-label-dialog-control-span="12"');
+ expect(source).toContain('sm:grid-cols-[minmax(96px,7fr)_minmax(0,12fr)]');
+ expect(source).toContain('isLabelPreviewVisible');
+ expect(source).toContain("draftLabel?.name !== undefined");
+ expect(source).toContain('data-label-dialog-preview-frame="angular-inline-tag-no-extra-frame"');
+ expect(source).toContain('data-label-dialog-preview-chrome="angular-nz-tag-inline"');
+ expect(source).toContain('data-label-dialog-preview-chrome-mode="angular-tag-only-no-extra-frame"');
+ expect(source).toContain('className={coldDialogFieldClassName}');
+ expect(source).not.toContain('className={`${coldDialogFieldClassName} rounded-[4px] border border-[#2b3039] bg-[#101217] p-3`}');
+ expect(source).toContain('data-label-dialog-name-state');
+ expect(source).toContain('data-label-dialog-name-validation="validation.required"');
+ expect(source).toContain('data-label-save-submit="angular-required-before-submit"');
+ expect(source).toContain('data-label-name-validation-trigger="angular-ok-marks-dirty"');
+ expect(source).toContain('data-label-save-submit-state');
+ expect(source).toContain('disabled={Boolean(isSavePending)}');
+ expect(source).toContain("data-label-save-ok-loading={isSavePending ? 'true' : 'false'}");
+ expect(source).toContain('data-label-save-ok-loading-owner="angular-nz-ok-loading"');
expect(source).toContain('data-label-toolbar="cold-table-toolbar"');
expect(source).toContain('data-label-search-owner="shared-search-row"');
- expect(source).toContain('data-label-table-shell="cold-dense-table"');
- expect(source).toContain('data-label-table="cold-label-table"');
- expect(source).toContain('data-label-empty-state="cold-table-empty"');
+ expect(source).toContain('data-label-search-submit="angular-enter-and-clear"');
+ expect(source).toContain('data-label-search-submit-owner="cold-search-row"');
+ expect(source).toContain("clearLabel={t('common.clear')}");
+ expect(source).toContain('onClear={onSearchClear}');
+ expect(source).toContain('data-label-card-grid="angular-card-grid"');
+ expect(source).toContain('data-label-card-column-contract="nz-xs-12-sm-8-md-6-lg-4"');
+ expect(source).toContain('data-label-card-shell="angular-card"');
+ expect(source).toContain('data-label-card-size="small"');
+ expect(source).toContain('data-label-card-content="angular-label-content"');
+ expect(source).toContain('data-label-card-description="angular-description"');
+ expect(source).toContain('data-label-card-description-display="angular-truthy-description-raw"');
+ expect(source).toContain('data-label-card-description-display-owner="label-manage-surface"');
+ expect(source).toContain('data-label-card-actions="angular-card-actions"');
+ expect(source).toContain('data-label-monitor-handoff="angular-routerlink-monitors-labels"');
+ expect(source).toContain('data-label-monitor-handoff-query={labelText}');
+ expect(source).toContain('data-label-monitor-handoff-owner="next-monitor-query-link"');
+ expect(source).toContain("t('setting.labels.action.copy-aria', actionLabelParams)");
+ expect(source).toContain("t('setting.labels.action.edit-aria', actionLabelParams)");
+ expect(source).toContain("t('setting.labels.action.delete-aria', actionLabelParams)");
+ expect(source).toContain('data-label-row-actions="angular-card-actions-contextual"');
+ expect(source).toContain('data-label-row-actions-owner="hertzbeat-ui-action-group"');
+ expect(source).toContain('data-label-row-action-owner="row-contextual-icon-button"');
+ expect(source).toContain('data-label-row-action-label={labelText}');
+ expect(source).toContain('data-label-empty-state="cold-card-empty"');
expect(source).not.toContain('data-label-summary-rail');
expect(source).not.toContain('coldLabelVisual.search.row');
expect(source).not.toContain('coldLabelVisual.search.input');
@@ -202,11 +1037,7 @@ describe('label manage surface', () => {
expect(source).not.toContain('WorkbenchPage');
expect(source).not.toContain("from './alert-surface-primitives'");
expect(source).not.toContain('AlertSurfacePanel');
- expect(source).not.toContain('data-label-manage-route="angular-label-cards"');
- expect(source).not.toContain('data-label-toolbar="angular-card-toolbar"');
- expect(source).not.toContain('data-label-card-grid="angular-card-grid"');
- expect(source).not.toContain('data-label-card-shell="angular-card"');
- expect(source).not.toContain('data-label-card-actions="angular-card-actions"');
+ expect(source).not.toContain('data-label-table="cold-label-table"');
expect(source).not.toContain('SurfaceSection');
expect(source).not.toContain('StatusState');
expect(source).not.toContain('ToolbarField');
diff --git a/web-next/components/pages/label-manage-surface.tsx b/web-next/components/pages/label-manage-surface.tsx
index 60ecfec966..e402e4ad9e 100644
--- a/web-next/components/pages/label-manage-surface.tsx
+++ b/web-next/components/pages/label-manage-surface.tsx
@@ -1,13 +1,15 @@
'use client';
import React from 'react';
+import Link from 'next/link';
import { Copy, Inbox, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { OverlayDialog } from '../workbench/overlay-dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { SearchRow } from '../ui/search-row';
+import { HzActionGroup, HzConfirmDialog, HzIconButton, HzInlineFeedback, HzLabelTag } from '@hertzbeat/ui';
import { coldOpsCatalogVisual } from '../../lib/cold-ops-visual';
-import { buildLabelDisplayName } from '../../lib/label-manage/view-model';
+import { buildLabelDisplayName, renderAngularLabelColor } from '../../lib/label-manage/view-model';
import type { LabelManagePageData } from '../../lib/label-manage/controller';
import type { Label } from '../../lib/types';
@@ -19,14 +21,26 @@ type LabelManageSurfaceProps = {
search: string;
draftLabel: Label | null;
isManageModalAdd: boolean;
+ isNameValidationVisible?: boolean;
+ deleteTarget: Label | null;
+ isLoadPending?: boolean;
+ isDeletePending?: boolean;
+ isSavePending?: boolean;
+ actionMessage?: string | null;
+ actionError?: string | null;
+ actionMeta?: string | null;
+ loadError?: string | null;
formatTime: (value?: number | string | null) => string;
onSearchChange: (value: string) => void;
onSearch: () => void;
+ onSearchClear: () => void;
onRefresh: () => void;
onNew: () => void;
onCopy: (label: Label) => void;
onEdit: (label: Label) => void;
- onDelete: (label: Label) => void;
+ onDeleteRequest: (label: Label) => void;
+ onDeleteCancel: () => void;
+ onDeleteConfirm: () => void;
onDraftChange: (patch: Partial<Label>) => void;
onCloseDialog: () => void;
onSaveDialog: () => void;
@@ -40,28 +54,13 @@ const coldButtonClassName =
const coldPrimaryButtonClassName =
'h-8 min-w-[104px] rounded-[3px] border-[#31405c] bg-[#182238] px-3 text-[12px] font-semibold text-[#d8e4ff] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:border-[#4e74f8] hover:bg-[#202a42] hover:text-white';
-const coldIconButtonClassName =
- 'h-8 w-8 min-w-0 rounded-[3px] border-[#2b3039] bg-[#101217] text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white';
-
const coldDialogInputClassName =
'h-8 rounded-[3px] border border-[#2b3039] bg-[#101217] px-3 text-[12px] font-semibold text-[#eef2f7] outline-none placeholder:text-[#6f7788] focus:border-[#4e74f8] focus:ring-2 focus:ring-[rgba(78,116,248,0.16)]';
-function isChineseTranslator(t: Translator): boolean {
- return t('common.refresh') === '刷新' || t('menu.advanced.labels') === '标签管理';
-}
+const coldDialogFieldClassName =
+ 'grid gap-2 text-[12px] font-semibold text-[#a9b0bb] sm:grid-cols-[minmax(96px,7fr)_minmax(0,12fr)] sm:items-start';
-function resolveCopy(t: Translator, key: string, fallback: string): string {
- const value = t(key);
- return value === key ? fallback : value;
-}
-
-function resolveLabelType(type: number | null | undefined, t: Translator): string {
- const chinese = isChineseTranslator(t);
- if (type === 0) return chinese ? '自动标签' : 'Auto label';
- if (type === 1) return chinese ? '用户标签' : 'User label';
- if (type === 2) return chinese ? '预置标签' : 'Preset label';
- return '-';
-}
+const coldDialogLabelClassName = 'pt-2 leading-4 sm:text-right';
export function LabelManageSurface({
t,
@@ -69,14 +68,25 @@ export function LabelManageSurface({
search,
draftLabel,
isManageModalAdd,
- formatTime,
+ isNameValidationVisible,
+ deleteTarget,
+ isLoadPending,
+ isDeletePending,
+ isSavePending,
+ actionMessage,
+ actionError,
+ actionMeta,
+ loadError,
onSearchChange,
onSearch,
+ onSearchClear,
onRefresh,
onNew,
onCopy,
onEdit,
- onDelete,
+ onDeleteRequest,
+ onDeleteCancel,
+ onDeleteConfirm,
onDraftChange,
onCloseDialog,
onSaveDialog
@@ -84,8 +94,20 @@ export function LabelManageSurface({
const labels = data.list.content ?? [];
const dialogTitle = isManageModalAdd ? t('label.new') : t('label.edit');
const displayName = draftLabel ? buildLabelDisplayName(draftLabel) : '';
- const emptyTitle = resolveCopy(t, 'setting.labels.empty.title', '暂无标签');
- const emptyCopy = resolveCopy(t, 'setting.labels.empty.copy', '请先新增标签。');
+ const previewDisplayName = displayName || t('common.none');
+ const isLabelPreviewVisible = Boolean(draftLabel) && draftLabel?.name !== undefined;
+ const isDraftNameMissing = Boolean(draftLabel) && (draftLabel.name == undefined || draftLabel.name.length === 0);
+ const shouldShowNameValidation = isDraftNameMissing && Boolean(isNameValidationVisible);
+ const isCardGridLoading = Boolean(isLoadPending || isDeletePending);
+ const isDeleteFailureFeedback = actionError === t('common.notify.delete-fail');
+ const saveFailureKey =
+ actionError === t('common.notify.new-fail')
+ ? 'common.notify.new-fail'
+ : actionError === t('common.notify.edit-fail')
+ ? 'common.notify.edit-fail'
+ : undefined;
+ const emptyTitle = t('setting.labels.empty.title');
+ const emptyCopy = t('setting.labels.empty.copy');
return (
<>
@@ -104,7 +126,7 @@ export function LabelManageSurface({
{t('menu.advanced.labels')}
</h1>
<p className="mt-4 max-w-[780px] text-[13px] leading-6 text-[#a9b0bb]">
- 统一管理监控标签名称、标签值和描述,用紧凑表格保持检索、复制和编辑动作在同一工作面里完成。
+ {t('setting.labels.copy')}
</p>
<div data-label-command-row="standard-equal-buttons" className={coldLabelVisual.button.row}>
<Button size="sm" variant="default" className={coldButtonClassName} onClick={onRefresh}>
@@ -120,94 +142,226 @@ export function LabelManageSurface({
</div>
</div>
- <div data-label-admin-layout="full-width-admin-list" className="space-y-5">
+ <div
+ data-label-admin-layout="full-width-admin-list"
+ data-label-save-feedback-contract="angular-new-edit-notify"
+ data-label-save-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-label-save-failure-contract="angular-new-edit-fail-notification"
+ data-label-save-failure-owner="hertzbeat-ui-inline-feedback"
+ data-label-load-failure-contract="angular-console-only-shell"
+ data-label-load-failure-owner="label-route-controller"
+ data-label-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-label-copy-feedback-contract="angular-copy-notify"
+ data-label-copy-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-label-delete-confirm-contract="angular-modal-confirm"
+ data-label-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-label-delete-confirm-state-contract="angular-modal-confirm-state"
+ data-label-delete-confirm-state-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-label-delete-confirm-close-contract="angular-close-before-delete-result"
+ data-label-delete-confirm-close-owner="route-state-contract"
+ data-label-delete-feedback-contract="angular-delete-notify"
+ data-label-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-label-delete-failure-contract="angular-delete-fail-notification"
+ data-label-delete-failure-owner="hertzbeat-ui-inline-feedback"
+ data-label-delete-query-contract="angular-repeated-ids-query"
+ data-label-delete-query-owner="route-mutation-contract"
+ data-label-card-loading-contract="angular-table-loading"
+ data-label-card-loading-owner="label-route-controller"
+ data-label-card-loading={isCardGridLoading ? 'true' : 'false'}
+ data-label-save-loading-contract="angular-nz-ok-loading"
+ data-label-save-loading-contract-owner="angular-nz-ok-loading"
+ data-label-save-trim-contract="angular-trim-before-save"
+ data-label-save-trim-owner="label-manage-controller"
+ data-label-optional-save-payload-contract="angular-preserve-undefined-optional-fields"
+ data-label-optional-save-payload-owner="label-manage-controller"
+ data-label-type-save-payload-contract="angular-preserve-implicit-type"
+ data-label-type-save-payload-owner="label-manage-controller"
+ data-label-display-format-contract="angular-format-label-name-raw-tag-value"
+ data-label-display-format-owner="label-view-model"
+ data-label-description-display-contract="angular-truthy-description-raw"
+ data-label-description-display-owner="label-manage-surface"
+ data-label-dialog-mask-closable-contract="angular-mask-closable-false"
+ data-label-dialog-mask-closable-owner="angular-nz-modal"
+ data-label-dialog-width-contract="angular-width-30-percent"
+ data-label-dialog-width-owner="angular-nz-modal"
+ data-label-dialog-field-layout-contract="angular-label-7-control-12"
+ data-label-dialog-field-layout-owner="route-form-field-grid"
+ data-label-dialog-preview-visibility-contract="angular-name-defined-preview"
+ data-label-dialog-preview-visibility-owner="route-form-state"
+ data-label-dialog-preview-frame-contract="angular-inline-tag-no-extra-frame"
+ data-label-dialog-preview-frame-owner="route-form-field-grid"
+ data-label-dialog-preview-chrome-contract="angular-tag-only-no-extra-frame"
+ data-label-dialog-preview-chrome-owner="hertzbeat-ui-label-tag"
+ data-label-edit-reference-contract="angular-edit-direct-reference"
+ data-label-edit-reference-owner="route-form-state"
+ data-label-name-validation-contract="angular-required-before-submit"
+ data-label-name-validation-trigger-contract="angular-ok-marks-dirty"
+ data-label-name-validation-trigger-owner="route-form-contract"
+ data-label-name-validation-raw-contract="angular-required-before-trim"
+ data-label-name-validation-raw-owner="route-form-contract"
+ data-label-query-contract="angular-load-all-labels"
+ data-label-query-owner="label-query-state"
+ data-label-query-param-order-contract="angular-page-index-size-type-search"
+ data-label-query-param-order-owner="label-query-state"
+ data-label-card-grid-contract="angular-card-grid"
+ data-label-card-grid-owner="hertzbeat-ui-label-tag"
+ data-label-monitor-handoff-contract="angular-routerlink-monitors-labels"
+ data-label-monitor-handoff-owner="next-monitor-query-link"
+ className="space-y-5"
+ >
<section className="min-w-0">
<SearchRow
data-label-toolbar="cold-table-toolbar"
data-label-search-owner="shared-search-row"
+ data-label-search-submit="angular-enter-and-clear"
+ data-label-search-submit-owner="cold-search-row"
value={search}
placeholder={t('label.search')}
searchLabel={t('common.search')}
+ clearLabel={t('common.clear')}
inputWidthClassName="w-[360px]"
onValueChange={onSearchChange}
onSearch={onSearch}
+ onClear={onSearchClear}
/>
+ {actionMessage || actionError ? (
+ <HzInlineFeedback
+ tone={actionError ? 'warning' : 'success'}
+ title={actionError || actionMessage}
+ meta={actionMeta ?? undefined}
+ variant="embedded"
+ data-label-action-feedback={actionError ? 'angular-action-error' : 'angular-action-success'}
+ data-label-action-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-label-save-feedback={actionError ? 'angular-save-error' : 'angular-save-success'}
+ data-label-save-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-label-save-failure={saveFailureKey ? 'angular-save-fail' : undefined}
+ data-label-save-failure-owner={saveFailureKey ? 'hertzbeat-ui-inline-feedback' : undefined}
+ data-label-save-feedback-title={saveFailureKey}
+ data-label-save-feedback-detail={saveFailureKey && actionMeta ? 'backend-message' : undefined}
+ data-label-delete-feedback={
+ isDeleteFailureFeedback
+ ? 'angular-delete-fail'
+ : actionMessage === t('common.notify.delete-success')
+ ? 'angular-delete-notify'
+ : undefined
+ }
+ data-label-delete-feedback-owner={isDeleteFailureFeedback ? 'hertzbeat-ui-inline-feedback' : undefined}
+ data-label-delete-feedback-title={isDeleteFailureFeedback ? 'common.notify.delete-fail' : undefined}
+ data-label-delete-feedback-detail={isDeleteFailureFeedback && actionMeta ? 'backend-message' : undefined}
+ />
+ ) : null}
+
<div
- data-label-table-shell="cold-dense-table"
- className="overflow-hidden rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_20px_56px_rgba(0,0,0,0.32)]"
+ data-label-card-grid="angular-card-grid"
+ data-label-card-grid-owner="hertzbeat-ui-label-tag"
+ data-label-card-column-contract="nz-xs-12-sm-8-md-6-lg-4"
+ data-label-card-loading={isCardGridLoading ? 'true' : 'false'}
+ data-label-card-loading-owner="label-route-controller"
+ aria-busy={isCardGridLoading ? 'true' : 'false'}
+ className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
>
- <table data-label-table="cold-label-table" className="w-full table-fixed border-collapse text-left text-[12px] text-[#a9b0bb]">
- <thead className="border-b border-[#252b34] bg-[#101217] text-[11px] font-semibold uppercase tracking-[0.12em] text-[#7e8494]">
- <tr>
- <th className="w-[24%] px-3 py-2.5">标签名称</th>
- <th className="w-[18%] px-3 py-2.5">标签类型</th>
- <th className="w-[28%] px-3 py-2.5">标签描述</th>
- <th className="w-[18%] px-3 py-2.5">{t('common.edit-time')}</th>
- <th className="w-[96px] px-3 py-2.5">{t('common.edit')}</th>
- </tr>
- </thead>
- <tbody>
- {labels.length > 0 ? labels.map(label => {
- const labelText = buildLabelDisplayName(label);
- const copyLabel = t('common.copy.button');
- const editLabel = t('common.button.edit');
- const deleteLabel = t('common.button.delete');
+ {labels.length > 0 ? labels.map(label => {
+ const labelText = buildLabelDisplayName(label);
+ const description = label.description ? label.description : '';
+ const actionLabelParams = { name: labelText };
+ const copyLabel = t('setting.labels.action.copy-aria', actionLabelParams);
+ const editLabel = t('setting.labels.action.edit-aria', actionLabelParams);
+ const deleteLabel = t('setting.labels.action.delete-aria', actionLabelParams);
- return (
- <tr key={label.id} data-label-row={label.id} className="border-t border-[#252b34] bg-[#0b0c0e] transition hover:bg-[#111721]">
- <td className="px-3 py-2.5 font-semibold text-[#eef2f7]">
- <a
- href={`/monitors?labels=${encodeURIComponent(labelText)}`}
- className="inline-flex max-w-full rounded-[3px] border border-[#303743] bg-[#101217] px-2 py-0.5 text-[12px] leading-5 text-[#dbe4f0] hover:border-[#4e74f8] hover:text-white"
- >
- <span className="truncate">{labelText}</span>
- </a>
- </td>
- <td className="px-3 py-2.5">
- <span className="rounded-[3px] border border-[#303743] bg-[#101217] px-2 py-0.5 text-[11px] leading-4 text-[#cbd5e1]">
- {resolveLabelType(label.type, t)}
- </span>
- </td>
- <td className="truncate px-3 py-2.5" title={label.description || '-'}>
- {label.description || '-'}
- </td>
- <td className="px-3 py-2.5 text-[#858d9a]">
- {formatTime(label.gmtUpdate || label.gmtCreate || null)}
- </td>
- <td className="px-3 py-2.5">
- <div data-label-row-actions="cold-icon-actions" className="flex gap-1.5">
- <Button size="icon" variant="default" className={coldIconButtonClassName} aria-label={copyLabel} title={copyLabel} onClick={() => onCopy(label)}>
- <Copy className="h-3.5 w-3.5" aria-hidden="true" />
- </Button>
- <Button size="icon" variant="default" className={coldIconButtonClassName} aria-label={editLabel} title={editLabel} onClick={() => onEdit(label)}>
- <Pencil className="h-3.5 w-3.5" aria-hidden="true" />
- </Button>
- <Button size="icon" variant="default" className={coldIconButtonClassName} aria-label={deleteLabel} title={deleteLabel} onClick={() => onDelete(label)}>
- <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
- </Button>
- </div>
- </td>
- </tr>
- );
- }) : (
- <tr data-label-empty-state="cold-table-empty" className="border-t border-[#252b34] bg-[#0b0c0e]">
- <td colSpan={5} className="h-[240px] px-3 text-center text-[#a9b0bb]">
- <div className="inline-flex flex-col items-center gap-2.5">
- <span
- data-label-empty-icon="cold-empty-box"
- className="inline-flex h-10 w-10 items-center justify-center rounded-[4px] border border-[#303743] bg-[#101217] text-[#cbd5e1]"
- >
- <Inbox className="h-5 w-5" aria-hidden="true" />
- </span>
- <div className="text-[13px] font-semibold text-[#eef2f7]">{emptyTitle}</div>
- <div className="text-[12px] leading-5 text-[#8f99ab]">{emptyCopy}</div>
+ return (
+ <article
+ key={label.id}
+ data-label-row={label.id}
+ data-label-card-shell="angular-card"
+ data-label-card-size="small"
+ className="flex min-h-[112px] min-w-0 flex-col overflow-hidden rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_12px_32px_rgba(0,0,0,0.28)] transition hover:border-[#4e74f8] hover:bg-[#111721]"
+ >
+ <div data-label-card-content="angular-label-content" className="flex min-h-[72px] flex-1 flex-col gap-2 p-3">
+ <Link
+ href={`/monitors?labels=${encodeURIComponent(labelText)}`}
+ title={labelText}
+ className="inline-flex max-w-full"
+ data-label-monitor-handoff="angular-routerlink-monitors-labels"
+ data-label-monitor-handoff-query={labelText}
+ data-label-monitor-handoff-owner="next-monitor-query-link"
+ >
+ <HzLabelTag
+ colorToken={renderAngularLabelColor(label.name || '')}
+ className="hover:border-[#4e74f8]"
+ data-label-color-token={renderAngularLabelColor(label.name || '')}
+ data-label-color-owner="hertzbeat-ui-label-tag"
+ >
+ <span className="truncate">{labelText}</span>
+ </HzLabelTag>
+ </Link>
+ {description ? (
+ <div
+ data-label-card-description="angular-description"
+ data-label-card-description-display="angular-truthy-description-raw"
+ data-label-card-description-display-owner="label-manage-surface"
+ className="line-clamp-2 min-h-[34px] text-[12px] leading-[17px] text-[#8f99ab]"
+ title={description}
+ >
+ {description}
</div>
- </td>
- </tr>
- )}
- </tbody>
- </table>
+ ) : null}
+ </div>
+ <div data-label-card-actions="angular-card-actions" className="border-t border-[#252b34] bg-[#101217] px-2 py-1.5">
+ <HzActionGroup
+ density="compact-icons"
+ layout="center"
+ data-label-row-actions="angular-card-actions-contextual"
+ data-label-row-actions-owner="hertzbeat-ui-action-group"
+ >
+ <HzIconButton
+ label={copyLabel}
+ intent="ghost"
+ onClick={() => onCopy(label)}
+ data-label-row-action="copy"
+ data-label-row-action-owner="row-contextual-icon-button"
+ data-label-row-action-label={labelText}
+ >
+ <Copy size={13} />
+ </HzIconButton>
+ <HzIconButton
+ label={editLabel}
+ intent="ghost"
+ onClick={() => onEdit(label)}
+ data-label-row-action="edit"
+ data-label-row-action-owner="row-contextual-icon-button"
+ data-label-row-action-label={labelText}
+ >
+ <Pencil size={13} />
+ </HzIconButton>
+ <HzIconButton
+ label={deleteLabel}
+ intent="ghost"
+ onClick={() => onDeleteRequest(label)}
+ data-label-row-action="delete"
+ data-label-row-action-owner="row-contextual-icon-button"
+ data-label-row-action-label={labelText}
+ >
+ <Trash2 size={13} />
+ </HzIconButton>
+ </HzActionGroup>
+ </div>
+ </article>
+ );
+ }) : (
+ <div data-label-empty-state="cold-card-empty" className="col-span-full h-[240px] rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] px-3 text-center text-[#a9b0bb]">
+ <div className="flex h-full flex-col items-center justify-center gap-2.5">
+ <span
+ data-label-empty-icon="cold-empty-box"
+ className="inline-flex h-10 w-10 items-center justify-center rounded-[4px] border border-[#303743] bg-[#101217] text-[#cbd5e1]"
+ >
+ <Inbox className="h-5 w-5" aria-hidden="true" />
+ </span>
+ <div className="text-[13px] font-semibold text-[#eef2f7]">{emptyTitle}</div>
+ <div className="text-[12px] leading-5 text-[#8f99ab]">{emptyCopy}</div>
+ </div>
+ </div>
+ )}
</div>
</section>
</div>
@@ -219,55 +373,168 @@ export function LabelManageSurface({
open={Boolean(draftLabel)}
title={dialogTitle}
onClose={onCloseDialog}
- maxWidthClassName="max-w-2xl"
+ maskClosable={false}
+ maxWidthClassName="max-w-[min(92vw,30rem)] lg:max-w-[30vw]"
+ overlayProps={{
+ 'data-label-dialog-mask-closable': 'false',
+ 'data-label-dialog-mask-closable-owner': 'angular-nz-modal',
+ 'data-label-dialog-width': 'angular-width-30-percent',
+ 'data-label-dialog-width-owner': 'angular-nz-modal',
+ 'data-label-dialog-field-layout': 'angular-label-7-control-12',
+ 'data-label-dialog-field-layout-owner': 'route-form-field-grid',
+ 'data-label-dialog-preview-visibility': isLabelPreviewVisible ? 'visible' : 'hidden',
+ 'data-label-dialog-preview-visibility-owner': 'route-form-state'
+ }}
footer={
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="default" className={coldButtonClassName} onClick={onCloseDialog}>
{t('common.button.cancel')}
</Button>
- <Button size="sm" variant="default" className={coldPrimaryButtonClassName} onClick={onSaveDialog}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldPrimaryButtonClassName}
+ onClick={onSaveDialog}
+ disabled={Boolean(isSavePending)}
+ aria-busy={isSavePending ? 'true' : 'false'}
+ data-label-save-submit="angular-required-before-submit"
+ data-label-name-validation-trigger="angular-ok-marks-dirty"
+ data-label-name-validation-trigger-owner="route-form-contract"
+ data-label-save-submit-state={shouldShowNameValidation ? 'dirty-invalid' : 'pristine'}
+ data-label-save-trim="angular-trim-before-save"
+ data-label-save-trim-owner="label-manage-controller"
+ data-label-save-ok-loading={isSavePending ? 'true' : 'false'}
+ data-label-save-ok-loading-owner="angular-nz-ok-loading"
+ >
{t('common.button.save')}
</Button>
</div>
}
>
{draftLabel ? (
- <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
- <div className="space-y-3">
- <label className="flex flex-col gap-2 text-[12px] font-semibold text-[#a9b0bb]">
- <span>{t('label.name')}</span>
+ <div className="grid gap-3" data-label-dialog-field-layout="angular-label-7-control-12" data-label-dialog-field-layout-owner="route-form-field-grid">
+ <div
+ className={coldDialogFieldClassName}
+ data-label-dialog-field="name"
+ data-label-dialog-field-layout-row="angular-label-7-control-12"
+ >
+ <label htmlFor="label-name" className={coldDialogLabelClassName} data-label-dialog-label-span="7">
+ {t('label.name')}
+ </label>
+ <div className="min-w-0" data-label-dialog-control-span="12">
<Input
+ id="label-name"
className={coldDialogInputClassName}
value={draftLabel.name || ''}
onChange={event => onDraftChange({ name: event.target.value })}
+ data-label-dialog-name-input="angular-required-name"
+ data-label-dialog-name-state={shouldShowNameValidation ? 'dirty-invalid' : 'pristine'}
/>
+ {shouldShowNameValidation ? (
+ <span className="mt-1 block text-[11px] font-medium text-[#fca5a5]" data-label-dialog-name-validation="validation.required">
+ {t('validation.required')}
+ </span>
+ ) : null}
+ </div>
+ </div>
+ <div
+ className={coldDialogFieldClassName}
+ data-label-dialog-field="value"
+ data-label-dialog-field-layout-row="angular-label-7-control-12"
+ >
+ <label htmlFor="label-value" className={coldDialogLabelClassName} data-label-dialog-label-span="7">
+ {t('label.value')}
</label>
- <label className="flex flex-col gap-2 text-[12px] font-semibold text-[#a9b0bb]">
- <span>{t('label.value')}</span>
+ <div className="min-w-0" data-label-dialog-control-span="12">
<Input
+ id="label-value"
className={coldDialogInputClassName}
value={draftLabel.tagValue || ''}
onChange={event => onDraftChange({ tagValue: event.target.value })}
/>
+ </div>
+ </div>
+ <div
+ className={coldDialogFieldClassName}
+ data-label-dialog-field="description"
+ data-label-dialog-field-layout-row="angular-label-7-control-12"
+ >
+ <label htmlFor="label-description" className={coldDialogLabelClassName} data-label-dialog-label-span="7">
+ {t('label.description')}
</label>
- <label className="flex flex-col gap-2 text-[12px] font-semibold text-[#a9b0bb]">
- <span>{t('label.description')}</span>
+ <div className="min-w-0" data-label-dialog-control-span="12">
<Input
+ id="label-description"
className={coldDialogInputClassName}
value={draftLabel.description || ''}
onChange={event => onDraftChange({ description: event.target.value })}
/>
- </label>
- </div>
- <div data-label-dialog-preview="cold-preview" className="rounded-[4px] border border-[#2b3039] bg-[#101217] p-3">
- <div className="mb-3 text-[13px] font-semibold text-[#eef2f7]">{t('label.display')}</div>
- <div className="inline-flex min-h-8 max-w-full items-center rounded-[3px] border border-[#303743] bg-[#0b0c0e] px-2.5 text-[12px] font-semibold text-[#dbe4f0]">
- <span className="truncate">{displayName || '-'}</span>
</div>
</div>
+ {isLabelPreviewVisible ? (
+ <div
+ data-label-dialog-preview="cold-preview"
+ data-label-dialog-preview-visibility="visible"
+ data-label-dialog-preview-visibility-owner="route-form-state"
+ data-label-dialog-preview-frame="angular-inline-tag-no-extra-frame"
+ data-label-dialog-preview-frame-owner="route-form-field-grid"
+ data-label-dialog-field="display"
+ data-label-dialog-field-layout-row="angular-label-7-control-12"
+ className={coldDialogFieldClassName}
+ >
+ <div className={coldDialogLabelClassName} data-label-dialog-label-span="7">
+ {t('label.display')}
+ </div>
+ <HzLabelTag
+ colorToken={renderAngularLabelColor(draftLabel.name || '')}
+ className="max-w-full align-middle"
+ data-label-dialog-preview-color="angular-render-label-color"
+ data-label-dialog-preview-chrome="angular-nz-tag-inline"
+ data-label-dialog-preview-chrome-mode="angular-tag-only-no-extra-frame"
+ data-label-dialog-preview-chrome-owner="hertzbeat-ui-label-tag"
+ data-label-color-owner="hertzbeat-ui-label-tag"
+ data-label-dialog-control-span="12"
+ >
+ <span className="truncate">{previewDisplayName}</span>
+ </HzLabelTag>
+ </div>
+ ) : (
+ <div hidden data-label-dialog-preview-visibility="hidden" data-label-dialog-preview-visibility-owner="route-form-state" />
+ )}
</div>
) : null}
</OverlayDialog>
+ <div
+ hidden
+ data-label-delete-confirm-state={deleteTarget ? 'open' : 'closed'}
+ data-label-delete-confirm-state-owner="hertzbeat-ui-confirm-dialog"
+ data-label-delete-confirm-pending={isDeletePending ? 'true' : 'false'}
+ data-label-delete-confirm-state-target={deleteTarget ? buildLabelDisplayName(deleteTarget) : ''}
+ />
+ <HzConfirmDialog
+ open={Boolean(deleteTarget)}
+ tone="critical"
+ kicker={t('menu.advanced.labels')}
+ title={t('common.confirm.delete')}
+ cancelLabel={t('common.button.cancel')}
+ confirmLabel={t('common.button.ok')}
+ confirmDisabled={Boolean(isDeletePending)}
+ onClose={onDeleteCancel}
+ onConfirm={onDeleteConfirm}
+ bodyRhythm="stack"
+ data-label-delete-confirm="angular-modal-confirm"
+ data-label-delete-confirm-owner="hertzbeat-ui-confirm-dialog"
+ data-label-delete-confirm-state={deleteTarget ? 'open' : 'closed'}
+ confirmButtonProps={{
+ 'data-label-delete-confirm-submit': 'angular-modal-confirm',
+ 'data-label-delete-confirm-close': 'angular-close-before-delete-result',
+ 'data-label-delete-confirm-close-owner': 'route-state-contract'
+ } as React.ComponentProps<typeof HzConfirmDialog>['confirmButtonProps']}
+ >
+ <div data-label-delete-confirm-target={deleteTarget ? buildLabelDisplayName(deleteTarget) : ''}>
+ {deleteTarget ? buildLabelDisplayName(deleteTarget) : t('common.none')}
+ </div>
+ </HzConfirmDialog>
</>
);
}
diff --git a/web-next/components/pages/plugin-manage-surface.test.tsx b/web-next/components/pages/plugin-manage-surface.test.tsx
index e36d828059..59f980c6b8 100644
--- a/web-next/components/pages/plugin-manage-surface.test.tsx
+++ b/web-next/components/pages/plugin-manage-surface.test.tsx
@@ -11,6 +11,7 @@ describe('plugin manage surface', () => {
const t = createTranslatorMock({
locale: 'zh-CN',
overrides: {
+ 'common.none': '无类型',
'common.file.select': '选择文件'
}
});
@@ -32,7 +33,7 @@ describe('plugin manage surface', () => {
id: 2,
name: 'slack',
enableStatus: false,
- items: [{ type: 'POST_ALERT' }],
+ items: [],
paramCount: 0
}
],
@@ -48,8 +49,13 @@ describe('plugin manage surface', () => {
jarFileName: 'smtp.jar',
enableStatus: true
}}
+ actionMessage="Edit Success!"
+ actionKind="enable"
+ isTogglePending
+ optimisticEnableStatus={{ 2: false }}
onSearchChange={() => {}}
onSearch={() => {}}
+ onSearchClear={() => {}}
onRefresh={() => {}}
onNew={() => {}}
onDeleteSelected={() => {}}
@@ -67,20 +73,96 @@ describe('plugin manage surface', () => {
expect(html).toContain('data-plugin-header="cold-compact-header"');
expect(html).toContain('data-plugin-command-row="standard-equal-buttons"');
expect(html).toContain('data-plugin-admin-layout="full-width-admin-list"');
+ expect(html).toContain('data-plugin-enable-feedback-contract="angular-edit-notify"');
+ expect(html).toContain('data-plugin-enable-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-enable-optimistic-contract="angular-toggle-mutates-row-before-put"');
+ expect(html).toContain('data-plugin-enable-optimistic-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-enable-optimistic-state="overridden"');
+ expect(html).toContain('data-plugin-enable-optimistic-next-status="false"');
+ expect(html).toContain('data-plugin-table-loading-contract="angular-load-plugins-table"');
+ expect(html).toContain('data-plugin-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(html).toContain('data-plugin-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-plugin-load-failure-owner="plugin-route-controller"');
+ expect(html).toContain('data-plugin-load-failure="none"');
+ expect(html).toContain('data-plugin-enable-feedback="angular-edit-notify"');
+ expect(html).toContain('data-plugin-enable-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-toggle-loading-contract="angular-nz-table-loading"');
+ expect(html).toContain('data-plugin-toggle-loading-contract-owner="angular-nz-table-loading"');
+ expect(html).toContain('data-plugin-selection-reset-contract="angular-load-clears-selection"');
+ expect(html).toContain('data-plugin-selection-reset-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-table-columns-contract="angular-five-column-edit-actions"');
+ expect(html).toContain('data-plugin-table-columns-owner="angular-nz-table"');
+ expect(html).toContain('data-plugin-table-stable-height-contract="viewport-fill-on-empty-or-short"');
+ expect(html).toContain('data-plugin-table-stable-height-owner="route-layout-contract"');
+ expect(html).toContain('data-plugin-table-loading="true"');
+ expect(html).toContain('data-plugin-table-loading-owner="angular-nz-table-loading"');
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('Edit Success!');
expect(html).toContain('data-plugin-manage-toolbar="cold-table-toolbar"');
expect(html).toContain('data-plugin-manage-search-owner="shared-search-row"');
+ expect(html).toContain('data-plugin-search-submit-contract="angular-enter-and-clear"');
+ expect(html).toContain('data-plugin-search-clear-contract="angular-cleared-load"');
+ expect(html).toContain('data-plugin-search-clear-owner="shared-search-row"');
expect(html).toContain('data-cold-search-row-owner="cold-search-row"');
expect(html).toContain('data-cold-search-input="fixed-width-direct"');
expect(html).toContain('data-cold-search-control="direct-input"');
expect(html).toContain('data-cold-search-chrome="no-extra-input-shell"');
expect(html).toContain('data-cold-search-action="submit"');
+ expect(html).toContain('data-cold-search-action="clear"');
expect(html).toContain('data-cold-search-trailing-actions="detached-secondary"');
expect(html).toContain('data-plugin-manage-table-shell="cold-dense-table"');
+ expect(html).toContain('data-plugin-table-stable-height="viewport-fill-on-empty-or-short"');
+ expect(html).toContain('data-plugin-table-fill-stage="viewport-fill-on-empty-or-short"');
expect(html).toContain('data-plugin-manage-table="cold-plugin-table"');
+ expect(html).toContain('data-plugin-table-columns="angular-five-column-edit-actions"');
+ expect(html).not.toContain('>参数</th>');
+ expect(html).toContain('data-plugin-pagination="cold-dense-pagination"');
+ expect(html).toContain('data-plugin-pagination-owner="hertzbeat-ui-pagination-bar"');
+ expect(html).toContain('data-plugin-pagination-contract="angular-search-pagination"');
+ expect(html).toContain('data-plugin-query-param-order-contract="angular-page-index-size-search"');
+ expect(html).toContain('data-plugin-query-param-order-owner="plugin-query-state"');
+ expect(html).toContain('data-hz-pagination-page-size="select-menu"');
+ expect(html).toContain('data-hz-pagination-page-jump="number-input"');
+ expect(html).toContain('data-plugin-pagination-page-jump-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-plugin-pagination-page-size-owner="hertzbeat-ui-select"');
expect(html).toContain('data-plugin-select-all="cold-checkbox"');
+ expect(html).toContain('data-plugin-action-menu-clipping="none"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu="angular-toolbar-ellipsis-delete"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-contract="angular-toolbar-ellipsis-delete"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-layer="overlay-visible-above-panel"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-open="false"');
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-trigger="angular-toolbar-ellipsis-delete"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-panel="angular-toolbar-ellipsis-delete"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-layer-panel="overlay-visible-above-panel"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-clearance-panel="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-plugin-toolbar-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(html).toContain('data-plugin-row-delete-menu-layer="overlay-visible-above-panel"');
+ expect(html).toContain('data-plugin-row-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-plugin-row-delete-menu-open="false"');
+ expect(html).toContain('data-plugin-row-delete-menu-layer-panel="overlay-visible-above-panel"');
+ expect(html).toContain('data-plugin-row-delete-menu-clearance-panel="floating-overlay-no-panel-crop"');
+ expect(html).toContain('data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-plugin-row-delete-menu-panel-open="false"');
+ expect(html).toContain('data-plugin-delete-selected="angular-batch-delete-entry"');
+ expect(html).toContain('data-plugin-delete-selected-owner="hertzbeat-ui-table-row-action-button"');
expect(html).toContain('data-plugin-row="1"');
expect(html).toContain('data-plugin-row="2"');
- expect(html).toContain('data-plugin-row-actions="cold-icon-actions"');
+ expect(html).toContain('data-plugin-row-delete-menu="1"');
+ expect(html).toContain('data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(html).toContain('data-plugin-row-delete-menu-trigger="1"');
+ expect(html).toContain('data-plugin-row-delete-menu-panel="1"');
+ expect(html).toContain('data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-plugin-delete-one="1"');
+ expect(html).toContain('data-plugin-delete-one-owner="hertzbeat-ui-table-row-action-button"');
+ expect(html).toContain('data-plugin-row-actions="angular-row-actions-contextual"');
+ expect(html).toContain('data-plugin-row-actions-owner="cold-icon-actions"');
+ expect(html).toContain('data-plugin-row-action="params"');
+ expect(html).toContain('data-plugin-row-action="delete"');
+ expect(html).toContain('data-plugin-row-action-owner="row-contextual-icon-button"');
+ expect(html).toContain('data-plugin-row-action-label="smtp"');
expect(html).not.toContain('data-plugin-summary-rail=');
expect(html).not.toContain('data-cold-search-input-shell');
expect(html).toContain('插件管理');
@@ -94,13 +176,29 @@ describe('plugin manage surface', () => {
expect(html).toContain('slack');
expect(html).toContain('告警后');
expect(html).toContain('采集后');
+ expect(html).toContain('无类型');
expect(html).toContain('已启用');
expect(html).toContain('已停用');
expect(html).toContain('已选择');
+ expect(html).toContain('第 1 / 1 页 · 1-2 / 2');
expect(html).not.toContain('POST_ALERT');
expect(html).not.toContain('POST_COLLECT');
- expect(html).toContain('编辑参数');
+ expect(html).toContain('编辑插件参数 smtp');
+ expect(html).toContain('删除插件 smtp');
expect(html).toContain('data-overlay-dialog="true"');
+ expect(html).toContain('data-overlay-dialog-mask-closable="false"');
+ expect(html).toContain('data-plugin-upload-mask-closable="false"');
+ expect(html).toContain('data-plugin-upload-width="angular-width-30-percent"');
+ expect(html).toContain('data-plugin-upload-modal-owner="angular-nz-modal"');
+ expect(html).toContain('data-plugin-upload-field-layout-contract="angular-label-8-control-14"');
+ expect(html).toContain('data-plugin-upload-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-plugin-upload-field-layout="angular-label-8-control-14"');
+ expect(html).toContain('data-plugin-upload-label-span="8"');
+ expect(html).toContain('data-plugin-upload-control-span="14"');
+ expect(html).toContain('data-plugin-upload-status-control="angular-nz-switch"');
+ expect(html).toContain('data-plugin-upload-status-control-owner="hertzbeat-ui-switch"');
+ expect(html).toContain('data-hz-ui="switch"');
+ expect(html).toContain('role="switch"');
expect(html).toContain('Jar包');
expect(html).toContain('选择文件');
expect(html).toContain('取消');
@@ -143,12 +241,62 @@ describe('plugin manage surface', () => {
);
expect(html).toContain('data-plugin-manage-table-shell="cold-dense-table"');
+ expect(html).toContain('data-plugin-table-stable-height="viewport-fill-on-empty-or-short"');
+ expect(html).toContain('data-plugin-table-stable-height-owner="route-layout-contract"');
+ expect(html).toContain('data-plugin-table-fill-stage="viewport-fill-on-empty-or-short"');
expect(html).toContain('data-plugin-manage-table="cold-plugin-table"');
+ expect(html).toContain('colSpan="5"');
expect(html).toContain('data-plugin-manage-empty-state="cold-table-empty"');
expect(html).toContain('data-plugin-manage-empty-icon="cold-empty-box"');
expect(html).toContain('暂无数据');
});
+ it('keeps the Angular plugin management shell mounted without showing backend load errors', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ loadError="backend refused plugin load"
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-manage-surface="otlp-cold-plugin-console"');
+ expect(html).toContain('data-plugin-load-failure-contract="angular-console-only-shell"');
+ expect(html).toContain('data-plugin-load-failure-owner="plugin-route-controller"');
+ expect(html).toContain('data-plugin-load-failure="angular-console-only-shell"');
+ expect(html).toContain('data-plugin-manage-toolbar="cold-table-toolbar"');
+ expect(html).toContain('data-plugin-manage-table-shell="cold-dense-table"');
+ expect(html).toContain('data-plugin-manage-empty-state="cold-table-empty"');
+ expect(html).toContain('插件管理');
+ expect(html).not.toContain('backend refused plugin load');
+ expect(html).not.toContain('data-observability-status');
+ });
+
it('keeps the plugin page on the cold visual owner instead of WorkbenchPage or alert primitives', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/plugin-manage-surface.tsx'), 'utf8');
@@ -160,10 +308,240 @@ describe('plugin manage surface', () => {
expect(source).toContain('data-plugin-header="cold-compact-header"');
expect(source).toContain('data-plugin-command-row="standard-equal-buttons"');
expect(source).toContain('data-plugin-admin-layout="full-width-admin-list"');
+ expect(source).toContain('data-plugin-enable-feedback-contract="angular-edit-notify"');
+ expect(source).toContain('data-plugin-enable-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-plugin-enable-optimistic-contract="angular-toggle-mutates-row-before-put"');
+ expect(source).toContain('data-plugin-enable-optimistic-owner="route-state-contract"');
+ expect(source).toContain('const resolveEnableStatus = (plugin: Plugin) => (');
+ expect(source).toContain("data-plugin-enable-optimistic-state={hasOptimisticEnableStatus ? 'overridden' : 'source'}");
+ expect(source).toContain("data-plugin-enable-optimistic-next-status={displayEnableStatus ? 'true' : 'false'}");
+ expect(source).toContain('data-plugin-table-loading-contract="angular-load-plugins-table"');
+ expect(source).toContain('data-plugin-table-loading-scope="load-search-refresh-pagination-mutation"');
+ expect(source).toContain('data-plugin-load-failure-contract="angular-console-only-shell"');
+ expect(source).toContain('data-plugin-load-failure-owner="plugin-route-controller"');
+ expect(source).toContain("data-plugin-load-failure={loadError ? 'angular-console-only-shell' : 'none'}");
+ expect(source).toContain("actionKind === 'enable'");
+ expect(source).toContain('data-plugin-enable-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-plugin-toggle-loading-contract="angular-nz-table-loading"');
+ expect(source).toContain('data-plugin-toggle-loading-contract-owner="angular-nz-table-loading"');
+ expect(source).toContain('data-plugin-selection-reset-contract="angular-load-clears-selection"');
+ expect(source).toContain('data-plugin-selection-reset-owner="route-state-contract"');
+ expect(source).toContain('data-plugin-table-columns-contract="angular-five-column-edit-actions"');
+ expect(source).toContain('data-plugin-table-columns-owner="angular-nz-table"');
+ expect(source).toContain('const tableLoading = Boolean(isLoadPending || isDeletePending || isTogglePending);');
+ expect(source).toContain("data-plugin-table-loading={tableLoading ? 'true' : 'false'}");
+ expect(source).toContain('data-plugin-table-loading-owner="angular-nz-table-loading"');
+ expect(source).toContain('data-plugin-delete-warning-contract="angular-no-select-warning"');
+ expect(source).toContain('data-plugin-delete-confirm-contract="angular-modal-confirm"');
+ expect(source).toContain('data-plugin-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(source).toContain('data-plugin-delete-feedback-contract="angular-delete-notify"');
+ expect(source).toContain('data-plugin-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-plugin-delete-failure-contract="angular-delete-fail-notification"');
+ expect(source).toContain('data-plugin-delete-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-plugin-enable-failure-contract="angular-edit-fail-notification"');
+ expect(source).toContain('data-plugin-enable-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-plugin-delete-failure={isDeleteFailure ? \'angular-delete-fail-notification\' : undefined}');
+ expect(source).toContain('data-plugin-delete-feedback-title={');
+ expect(source).toContain("data-plugin-delete-feedback-detail={isDeleteFailure ? 'backend-message' : undefined}");
+ expect(source).toContain('data-plugin-enable-failure={isEnableFailure ? \'angular-edit-fail-notification\' : undefined}');
+ expect(source).toContain('data-plugin-enable-failure-owner={isEnableFailure ? \'hertzbeat-ui-inline-feedback\' : undefined}');
+ expect(source).toContain('data-plugin-enable-feedback-title={');
+ expect(source).toContain("data-plugin-enable-feedback-detail={isEnableFailure ? 'backend-message' : undefined}");
+ expect(source).toContain('data-plugin-delete-query-contract="angular-repeated-ids-query"');
+ expect(source).toContain('data-plugin-delete-query-owner="route-mutation-contract"');
+ expect(source).toContain('data-plugin-delete-page-clamp-contract="angular-update-page-index"');
+ expect(source).toContain('data-plugin-table-stable-height-contract="viewport-fill-on-empty-or-short"');
+ expect(source).toContain('data-plugin-table-stable-height-owner="route-layout-contract"');
+ expect(source).toContain('data-plugin-upload-validation-contract="angular-required-before-submit"');
+ expect(source).toContain('data-plugin-upload-invalid-submit-contract="angular-mark-dirty-keep-open"');
+ expect(source).toContain('data-plugin-upload-invalid-submit-owner="route-validation-contract"');
+ expect(source).toContain('data-plugin-upload-loading-contract="angular-nz-ok-loading"');
+ expect(source).toContain('data-plugin-upload-payload-contract="angular-form-data"');
+ expect(source).toContain('data-plugin-upload-name-payload-contract="angular-raw-name-no-trim"');
+ expect(source).toContain('data-plugin-upload-name-payload-owner="plugin-manage-controller"');
+ expect(source).toContain('data-plugin-upload-save-lifecycle-contract="angular-close-success-keep-open-fail"');
+ expect(source).toContain('data-plugin-upload-save-lifecycle-owner="route-state-contract"');
+ expect(source).toContain('data-plugin-upload-success-reset-contract="angular-reset-form-after-success"');
+ expect(source).toContain('data-plugin-upload-success-reset-owner="route-state-contract"');
+ expect(source).toContain('data-plugin-upload-cancel-contract="angular-cancel-preserves-form"');
+ expect(source).toContain('data-plugin-upload-cancel-contract-owner="route-state-contract"');
+ expect(source).toContain('data-plugin-upload-cancel-pending-contract="angular-cancel-allowed-during-ok-loading"');
+ expect(source).toContain('data-plugin-upload-cancel-pending-owner="angular-nz-modal"');
+ expect(source).toContain('data-plugin-upload-mask-closable-contract="angular-mask-closable-false"');
+ expect(source).toContain('data-plugin-upload-width-contract="angular-width-30-percent"');
+ expect(source).toContain('data-plugin-upload-field-layout-contract="angular-label-8-control-14"');
+ expect(source).toContain('data-plugin-upload-field-layout-owner="route-form-field-grid"');
+ expect(source).toContain('data-plugin-upload-field-layout="angular-label-8-control-14"');
+ expect(source).toContain('data-plugin-upload-label-span="8"');
+ expect(source).toContain('data-plugin-upload-control-span="14"');
+ expect(source).toContain('sm:grid-cols-[minmax(96px,8fr)_minmax(0,14fr)]');
+ expect(source).toContain('data-plugin-upload-status-control-contract="angular-nz-switch"');
+ expect(source).toContain('data-plugin-upload-status-control-owner="hertzbeat-ui-switch"');
+ expect(source).toContain('data-plugin-upload-file-remove-contract="angular-nz-remove-clears-jar"');
+ expect(source).toContain('data-plugin-upload-file-remove-owner="hertzbeat-ui-file-input"');
+ expect(source).toContain('data-plugin-upload-modal-owner="angular-nz-modal"');
+ expect(source).toContain('data-plugin-upload-feedback-contract="angular-new-notify"');
+ expect(source).toContain('data-plugin-upload-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain('data-plugin-upload-failure-contract="angular-new-fail-notification"');
+ expect(source).toContain('data-plugin-upload-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain("const isUploadFailure = actionKind === 'upload' && Boolean(actionError);");
+ expect(source).toContain("const isParamFailure = actionKind === 'params' && Boolean(actionError);");
+ expect(source).toContain("? t('common.notify.edit-fail')");
+ expect(source).toContain('const actionFeedbackMeta = isUploadFailure || isParamFailure || isEnableFailure || isDeleteFailure ? actionMeta ?? actionError ?? undefined : undefined;');
+ expect(source).toContain('data-plugin-upload-feedback-title={');
+ expect(source).toContain('data-plugin-upload-feedback-detail={');
+ expect(source).toContain('HzFileInput');
+ expect(source).toContain('data-plugin-upload-open="angular-upload-modal"');
+ expect(source).toContain('data-plugin-upload-file-input="angular-jar-before-upload"');
+ expect(source).toContain('data-plugin-upload-name-payload="angular-raw-name-no-trim"');
+ expect(source).toContain('data-plugin-upload-file-list="angular-before-upload-single-replace"');
+ expect(source).toContain('data-plugin-upload-cancel-pending="angular-cancel-allowed-during-ok-loading"');
+ expect(source).toContain('event.target.files?.[0]');
+ expect(source).toContain("pluginJarInputRef.current.value = '';");
+ expect(source).toContain('data-plugin-upload-file-remove="angular-nz-remove-clears-jar"');
+ expect(source).toContain("jarFileName: ''");
+ expect(source).toContain('data-plugin-upload-save="angular-form-data"');
+ expect(source).toContain('open={Boolean((isUploadDialogOpen ?? Boolean(draftPlugin)) && draftPlugin)}');
+ expect(source).toContain('data-plugin-upload-dialog="angular-cancel-preserves-form"');
+ expect(source).toContain('data-plugin-upload-mask-closable');
+ expect(source).toContain('data-plugin-upload-width');
+ expect(source).toContain('max-w-[min(92vw,30rem)] lg:max-w-[30vw]');
+ expect(source).toContain("data-plugin-upload-ok-loading={isUploadPending ? 'true' : 'false'}");
+ expect(source).toContain('data-plugin-upload-ok-loading-owner="angular-nz-ok-loading"');
+ expect(source).toContain('data-plugin-param-edit-contract="angular-params-define-modal"');
+ expect(source).toContain('data-plugin-param-action-visibility-contract="angular-paramcount-zero-only"');
+ expect(source).toContain('data-plugin-param-action-visibility-owner="plugin-view-model"');
+ expect(source).toContain('data-plugin-param-mask-closable-contract="angular-mask-closable-false"');
+ expect(source).toContain('data-plugin-param-modal-owner="angular-nz-modal"');
+ expect(source).toContain('data-plugin-param-mask-closable');
+ expect(source).toContain('data-plugin-param-save-contract="angular-params-post"');
+ expect(source).toContain('data-plugin-param-payload-contract="angular-object-values-payload"');
+ expect(source).toContain('data-plugin-param-save-lifecycle-contract="angular-close-success-keep-open-fail"');
+ expect(source).toContain('data-plugin-param-save-lifecycle-owner="route-state-contract"');
+ expect(source).toContain('data-plugin-param-save-loading-contract="angular-no-ok-loading"');
+ expect(source).toContain('data-plugin-param-save-loading-owner="angular-modal-ok-contract"');
+ expect(source).toContain('data-plugin-param-save-loading="angular-no-ok-loading"');
+ expect(source).toContain("data-plugin-param-save-pending={isParamPending ? 'true' : 'false'}");
+ expect(source).toContain('data-plugin-param-payload="angular-object-values-payload"');
+ expect(source).toContain('data-plugin-param-save-lifecycle="angular-close-success-keep-open-fail"');
+ expect(source).toContain('data-plugin-param-feedback-contract="angular-edit-notify"');
+ expect(source).toContain('data-plugin-param-failure-contract="angular-edit-fail-notification"');
+ expect(source).toContain('data-plugin-param-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(source).toContain("data-plugin-param-failure={isParamFailure ? 'angular-edit-fail-notification' : undefined}");
+ expect(source).toContain("data-plugin-param-feedback-title={");
+ expect(source).toContain("data-plugin-param-feedback-detail={isParamFailure ? 'backend-message' : undefined}");
+ expect(source).toContain('data-plugin-param-pending-editable-contract="angular-controls-remain-enabled"');
+ expect(source).toContain('data-plugin-param-pending-editable-owner="angular-param-modal"');
+ expect(source).toContain("data-plugin-param-pending-editable={isParamPending ? 'angular-controls-remain-enabled' : 'idle'}");
+ expect(source).not.toContain('disabled={isParamPending}');
+ expect(source).toContain('data-plugin-param-width-contract="angular-default-modal-width"');
+ expect(source).toContain('data-plugin-param-width-owner="angular-nz-modal"');
+ expect(source).toContain('maxWidthClassName="max-w-[min(92vw,520px)]"');
+ expect(source).toContain("'data-plugin-param-width': 'angular-default-modal-width'");
+ expect(source).toContain("'data-plugin-param-width-owner': 'angular-nz-modal'");
+ expect(source).not.toContain('maxWidthClassName="max-w-3xl"');
+ expect(source).toContain('HzRadioButtonGroup');
+ expect(source).toContain('data-plugin-param-radio-contract="angular-nz-radio-group"');
+ expect(source).toContain('data-plugin-param-radio-owner="hertzbeat-ui-radio-button-group"');
+ expect(source).toContain("define.type === 'radio' && (define.options ?? []).length > 0");
+ expect(source).toContain('data-plugin-param-radio="angular-nz-radio-group"');
+ expect(source).toContain('data-plugin-param-radio-field={define.field}');
+ expect(source).toContain('data-plugin-param-textarea-contract="angular-textarea-rows-8"');
+ expect(source).toContain('data-plugin-param-textarea-owner="hertzbeat-ui-textarea"');
+ expect(source).toContain('function getPluginParamTextareaRows(define: ParamDefine): 4 | 8');
+ expect(source).toContain("return define.type === 'json' || define.type === 'textarea' ? 8 : 4;");
+ expect(source).toContain('rows={getPluginParamTextareaRows(define)}');
+ expect(source).toContain("data-plugin-param-textarea={define.type === 'textarea' ? 'angular-textarea-rows-8' : undefined}");
+ expect(source).toContain("data-plugin-param-textarea-rows={define.type === 'textarea' ? '8' : undefined}");
+ expect(source).toContain('data-plugin-param-field-layout-contract="angular-label-7-control-8"');
+ expect(source).toContain('data-plugin-param-field-layout-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(source).toContain('data-plugin-param-form-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(source).toContain("t('setting.plugins.action.params-aria', actionLabelParams)");
+ expect(source).toContain("t('setting.plugins.action.delete-aria', actionLabelParams)");
+ expect(source).toContain('data-plugin-param-edit-open={String(original.id)}');
+ expect(source).toContain('data-plugin-row-actions="angular-row-actions-contextual"');
+ expect(source).toContain('data-plugin-row-actions-owner="cold-icon-actions"');
+ expect(source).toContain('data-plugin-row-action-owner="row-contextual-icon-button"');
+ expect(source).toContain('data-plugin-row-action-label={row.name}');
+ expect(source).toContain('const [openRowActionMenuId, setOpenRowActionMenuId] = React.useState<string | null>(null)');
+ expect(source).toContain('const rowActionMenuId = String(original.id);');
+ expect(source).toContain('const isRowActionMenuOpen = openRowActionMenuId === rowActionMenuId;');
+ expect(source).toContain('data-plugin-row-delete-menu={rowActionMenuId}');
+ expect(source).toContain('data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(source).toContain('data-plugin-row-delete-menu-layer="overlay-visible-above-panel"');
+ expect(source).toContain("data-plugin-row-delete-menu-open={isRowActionMenuOpen ? 'true' : 'false'}");
+ expect(source).toContain('aria-expanded={isRowActionMenuOpen}');
+ expect(source).toContain('setOpenRowActionMenuId(current => (current === rowActionMenuId ? null : rowActionMenuId))');
+ expect(source).toContain('data-plugin-row-delete-menu-trigger={rowActionMenuId}');
+ expect(source).toContain('hidden={!isRowActionMenuOpen}');
+ expect(source).toContain('data-plugin-row-delete-menu-panel={rowActionMenuId}');
+ expect(source).toContain('data-plugin-row-delete-menu-layer-panel="overlay-visible-above-panel"');
+ expect(source).toContain('data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain("data-plugin-row-delete-menu-panel-open={isRowActionMenuOpen ? 'true' : 'false'}");
+ expect(source).toContain('setOpenRowActionMenuId(null);');
+ expect(source).not.toContain('<details');
+ expect(source).not.toContain('</details>');
+ expect(source).toContain('data-plugin-param-dialog="angular-params-define-modal"');
+ expect(source).toContain('data-plugin-param-form="angular-dynamic-form-field"');
+ expect(source).toContain('data-plugin-param-field-layout="angular-label-7-control-8"');
+ expect(source).toContain('data-plugin-param-label-span="7"');
+ expect(source).toContain('data-plugin-param-control-span="8"');
+ expect(source).toContain('data-plugin-param-save="angular-params-post"');
+ expect(source).toContain('HzMonitorEditorFieldGrid');
+ expect(source).toContain('HzTextarea');
+ expect(source).toContain('HzPaginationBar');
+ expect(source).toContain('pageSizeOptions = [8, 15, 25]');
+ expect(source).toContain('data-plugin-pagination="cold-dense-pagination"');
+ expect(source).toContain('data-plugin-pagination-owner="hertzbeat-ui-pagination-bar"');
+ expect(source).toContain('data-plugin-pagination-contract="angular-search-pagination"');
+ expect(source).toContain('data-plugin-query-param-order-contract="angular-page-index-size-search"');
+ expect(source).toContain('data-plugin-query-param-order-owner="plugin-query-state"');
+ expect(source).toContain("t('setting.plugins.pagination.summary'");
+ expect(source).toContain("t('setting.plugins.pagination.page-size')");
+ expect(source).toContain("t('setting.plugins.pagination.page')");
+ expect(source).toContain('data-plugin-pagination-page-jump-owner');
+ expect(source).toContain('data-plugin-pagination-page-size-owner');
+ expect(source).toContain('data-plugin-delete-feedback={');
+ expect(source).toContain('HzTableRowActionButton');
+ expect(source).toContain('data-plugin-toolbar-delete-menu="angular-toolbar-ellipsis-delete"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-contract="angular-toolbar-ellipsis-delete"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-layer="overlay-visible-above-panel"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-open={isToolbarActionMenuOpen ?');
+ expect(source).toContain('setToolbarActionMenuOpen(open => !open)');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-trigger="angular-toolbar-ellipsis-delete"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-panel="angular-toolbar-ellipsis-delete"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-layer-panel="overlay-visible-above-panel"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-clearance-panel="floating-overlay-no-panel-crop"');
+ expect(source).toContain('data-plugin-toolbar-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain('data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"');
+ expect(source).toContain('data-plugin-row-delete-menu-clearance="floating-overlay-no-panel-crop"');
+ expect(source).toContain('data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain('pluginActionMenuRootClassName');
+ expect(source).toContain('pluginActionMenuPanelBaseClassName');
+ expect(source).toContain('data-plugin-action-menu-clipping="none"');
+ expect(source).toContain("style={{ overflow: 'visible' }}");
+ expect(source).toContain('z-[9999] inline-block overflow-visible');
+ expect(source).toContain('mt-2 z-[9999] min-w-[132px]');
+ expect(source).toContain('data-plugin-delete-selected="angular-batch-delete-entry"');
+ expect(source).toContain('data-plugin-delete-selected-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain('data-plugin-delete-one={String(original.id)}');
+ expect(source).toContain('data-plugin-delete-one-owner="hertzbeat-ui-table-row-action-button"');
+ expect(source).toContain('HzConfirmDialog');
+ expect(source).toContain('HzInlineFeedback');
expect(source).toContain('data-plugin-manage-toolbar="cold-table-toolbar"');
expect(source).toContain('data-plugin-manage-search-owner="shared-search-row"');
+ expect(source).toContain('data-plugin-search-submit-contract="angular-enter-and-clear"');
+ expect(source).toContain('data-plugin-search-clear-contract="angular-cleared-load"');
+ expect(source).toContain('data-plugin-search-clear-owner="shared-search-row"');
+ expect(source).toContain('onClear={onSearchClear}');
expect(source).toContain('data-plugin-manage-table-shell="cold-dense-table"');
+ expect(source).toContain('data-plugin-table-stable-height="viewport-fill-on-empty-or-short"');
+ expect(source).toContain('data-plugin-table-fill-stage="viewport-fill-on-empty-or-short"');
expect(source).toContain('data-plugin-manage-table="cold-plugin-table"');
+ expect(source).toContain('data-plugin-table-columns="angular-five-column-edit-actions"');
+ expect(source).toContain('colSpan={5}');
+ expect(source).not.toContain("t('setting.plugins.table.params')");
expect(source).toContain('data-plugin-manage-empty-state="cold-table-empty"');
expect(source).not.toContain('data-plugin-summary-rail');
expect(source).not.toContain('coldPluginVisual.search.row');
@@ -207,7 +585,7 @@ describe('plugin manage surface', () => {
expect(source).not.toContain('className="inline-flex min-h-7 items-center rounded-[2px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-raised)] px-2 text-[12px] text-[var(--ops-text-primary)]"');
});
- it('uses the shared cold checkbox for table selection and draft enable state', async () => {
+ it('uses cold checkboxes for table selection and the shared switch for draft enable state', async () => {
const { PluginManageSurface } = await import('./plugin-manage-surface');
const t = createTranslatorMock({ locale: 'zh-CN' });
@@ -262,17 +640,656 @@ describe('plugin manage surface', () => {
const source = readFileSync(resolve(process.cwd(), 'components/pages/plugin-manage-surface.tsx'), 'utf8');
expect(source).toContain("from '../ui/checkbox'");
+ expect(source).toContain('HzSwitch');
expect(source).toContain('data-plugin-select-all="cold-checkbox"');
expect(source).toContain('data-plugin-row-select={String(original.id)}');
- expect(source).toContain('data-plugin-draft-enabled-checkbox="cold-checkbox"');
+ expect(source).toContain('data-plugin-upload-status-control="angular-nz-switch"');
expect(source).not.toContain('className="h-3.5 w-3.5 accent-[#4e74f8]"');
expect(html).toContain('data-plugin-select-all="cold-checkbox"');
expect(html).toContain('data-plugin-row-select="11"');
expect(html).toContain('data-plugin-row-select="12"');
- expect(html).toContain('data-plugin-draft-enabled-checkbox="cold-checkbox"');
- expect(html.match(/data-cold-checkbox-owner="cold-checkbox"/g)?.length ?? 0).toBe(4);
- expect(html.match(/data-cold-checkbox-control="native-hidden"/g)?.length ?? 0).toBe(4);
- expect(html.match(/data-cold-checkbox-box="indicator"/g)?.length ?? 0).toBe(4);
- expect(html).toContain('data-cold-checkbox-label="true"');
+ expect(html).toContain('data-plugin-upload-status-control="angular-nz-switch"');
+ expect(html).toContain('data-plugin-upload-status-control-owner="hertzbeat-ui-switch"');
+ expect(html).toContain('data-hz-ui="switch"');
+ expect(html.match(/data-cold-checkbox-owner="cold-checkbox"/g)?.length ?? 0).toBe(3);
+ expect(html.match(/data-cold-checkbox-control="native-hidden"/g)?.length ?? 0).toBe(3);
+ expect(html.match(/data-cold-checkbox-box="indicator"/g)?.length ?? 0).toBe(3);
+ expect(html).toContain('data-hz-switch-owner="hertzbeat-ui-switch"');
+ });
+
+ it('renders Angular plugin delete warning, confirmation, feedback, and page-clamp contracts through shared UI', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ id: 21,
+ name: 'webhook',
+ enableStatus: true,
+ items: [{ type: 'POST_ALERT' }],
+ paramCount: 0
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ actionError="请先选择要删除的监控"
+ actionTone="warning"
+ actionKind="delete"
+ deleteTarget={{
+ ids: [21],
+ label: 'webhook',
+ mode: 'single'
+ }}
+ isDeletePending
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-delete-warning-contract="angular-no-select-warning"');
+ expect(html).toContain('data-plugin-delete-confirm-contract="angular-modal-confirm"');
+ expect(html).toContain('data-plugin-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-plugin-delete-feedback-contract="angular-delete-notify"');
+ expect(html).toContain('data-plugin-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-delete-query-contract="angular-repeated-ids-query"');
+ expect(html).toContain('data-plugin-delete-query-owner="route-mutation-contract"');
+ expect(html).toContain('data-plugin-delete-page-clamp-contract="angular-update-page-index"');
+ expect(html).toContain('data-plugin-query-param-order-contract="angular-page-index-size-search"');
+ expect(html).toContain('data-plugin-query-param-order-owner="plugin-query-state"');
+ expect(html).toContain('data-plugin-table-stable-height-contract="viewport-fill-on-empty-or-short"');
+ expect(html).toContain('data-plugin-table-stable-height-owner="route-layout-contract"');
+ expect(html).toContain('data-plugin-delete-feedback="angular-no-select-warning"');
+ expect(html).toContain('data-plugin-delete-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-delete-confirm="angular-modal-confirm"');
+ expect(html).toContain('data-plugin-delete-confirm-owner="hertzbeat-ui-confirm-dialog"');
+ expect(html).toContain('data-plugin-delete-confirm-submit="angular-modal-confirm"');
+ expect(html).toContain('data-plugin-delete-confirm-target="webhook"');
+ expect(html).toContain('请先选择要删除的监控');
+ expect(html).toContain('请确认是否删除!');
+ });
+
+ it('renders Angular plugin upload required validation, jar file input, and save feedback contracts', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={{
+ name: '',
+ jarFileName: '',
+ jarFile: null,
+ enableStatus: true
+ }}
+ actionMessage="Add Success!"
+ actionKind="upload"
+ uploadValidation={{ name: true, jarFile: true }}
+ isUploadPending
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-upload-validation-contract="angular-required-before-submit"');
+ expect(html).toContain('data-plugin-upload-invalid-submit-contract="angular-mark-dirty-keep-open"');
+ expect(html).toContain('data-plugin-upload-invalid-submit-owner="route-validation-contract"');
+ expect(html).toContain('data-plugin-upload-loading-contract="angular-nz-ok-loading"');
+ expect(html).toContain('data-plugin-upload-payload-contract="angular-form-data"');
+ expect(html).toContain('data-plugin-upload-name-payload-contract="angular-raw-name-no-trim"');
+ expect(html).toContain('data-plugin-upload-name-payload-owner="plugin-manage-controller"');
+ expect(html).toContain('data-plugin-upload-save-lifecycle-contract="angular-close-success-keep-open-fail"');
+ expect(html).toContain('data-plugin-upload-save-lifecycle-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-success-reset-contract="angular-reset-form-after-success"');
+ expect(html).toContain('data-plugin-upload-success-reset-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-cancel-contract="angular-cancel-preserves-form"');
+ expect(html).toContain('data-plugin-upload-cancel-contract-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-cancel-pending-contract="angular-cancel-allowed-during-ok-loading"');
+ expect(html).toContain('data-plugin-upload-cancel-pending-owner="angular-nz-modal"');
+ expect(html).toContain('data-plugin-upload-feedback-contract="angular-new-notify"');
+ expect(html).toContain('data-plugin-upload-feedback-contract-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-upload-failure-contract="angular-new-fail-notification"');
+ expect(html).toContain('data-plugin-upload-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-upload-field-layout-contract="angular-label-8-control-14"');
+ expect(html).toContain('data-plugin-upload-field-layout-owner="route-form-field-grid"');
+ expect(html).toContain('data-plugin-upload-field="name"');
+ expect(html).toContain('data-plugin-upload-field="jarFile"');
+ expect(html).toContain('data-plugin-upload-cancel-pending="angular-cancel-allowed-during-ok-loading"');
+ expect(html).toContain('data-plugin-upload-field="enableStatus"');
+ expect(html).toContain('data-plugin-upload-field-layout="angular-label-8-control-14"');
+ expect(html).toContain('data-plugin-upload-label-span="8"');
+ expect(html).toContain('data-plugin-upload-control-span="14"');
+ expect(html).toContain('data-plugin-upload-feedback="angular-new-notify"');
+ expect(html).toContain('data-plugin-upload-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-upload-name-input="angular-required"');
+ expect(html).toContain('data-plugin-upload-name-payload="angular-raw-name-no-trim"');
+ expect(html).toContain('data-plugin-upload-file-input="angular-jar-before-upload"');
+ expect(html).toContain('data-plugin-upload-file-input-owner="hertzbeat-ui-file-input"');
+ expect(html).toContain('data-plugin-upload-file-list="angular-before-upload-single-replace"');
+ expect(html).toContain('data-plugin-upload-file-list-owner="hertzbeat-ui-file-input"');
+ expect(html).toContain('data-plugin-upload-file-remove-contract="angular-nz-remove-clears-jar"');
+ expect(html).toContain('data-plugin-upload-file-remove-owner="hertzbeat-ui-file-input"');
+ expect(html).toContain('data-plugin-upload-file-remove="angular-nz-remove-clears-jar"');
+ expect(html).toContain('data-plugin-upload-file-remove-state="empty"');
+ expect(html).toContain('data-plugin-upload-file-trigger="angular-jar-before-upload"');
+ expect(html).toContain('data-plugin-upload-file-name="angular-jar-before-upload"');
+ expect(html).toContain('data-plugin-upload-status-field="angular-nz-switch"');
+ expect(html).toContain('data-plugin-upload-status-control="angular-nz-switch"');
+ expect(html).toContain('data-plugin-upload-status-control-owner="hertzbeat-ui-switch"');
+ expect(html).toContain('data-plugin-upload-dialog="angular-cancel-preserves-form"');
+ expect(html).toContain('data-plugin-upload-invalid-submit="angular-mark-dirty-keep-open"');
+ expect(html).toContain('data-plugin-upload-invalid-submit-owner="route-validation-contract"');
+ expect(html).toContain('data-plugin-upload-save="angular-form-data"');
+ expect(html).toContain('data-plugin-upload-save-lifecycle="angular-close-success-keep-open-fail"');
+ expect(html).toContain('data-plugin-upload-save-lifecycle-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-upload-ok-loading="true"');
+ expect(html).toContain('data-plugin-upload-ok-loading-owner="angular-nz-ok-loading"');
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('data-plugin-upload-validation="name-required"');
+ expect(html).toContain('data-plugin-upload-validation="jar-required"');
+ expect(html).toContain('Add Success!');
+ expect(html).toContain('请填充必填项!');
+ });
+
+ it('renders Angular plugin upload failure title with backend detail', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ actionError="backend rejected duplicate plugin"
+ actionKind="upload"
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-upload-feedback="angular-new-fail"');
+ expect(html).toContain('data-plugin-upload-feedback-title="common.notify.new-fail"');
+ expect(html).toContain('data-plugin-upload-feedback-detail="backend-message"');
+ expect(html).toContain('新增失败!');
+ expect(html).toContain('backend rejected duplicate plugin');
+ });
+
+ it('renders Angular plugin delete failure title with backend detail', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ actionError="删除失败!"
+ actionMeta="backend refused plugin delete"
+ actionKind="delete"
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-delete-feedback="angular-delete-fail"');
+ expect(html).toContain('data-plugin-delete-failure="angular-delete-fail-notification"');
+ expect(html).toContain('data-plugin-delete-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-delete-feedback-title="common.notify.delete-fail"');
+ expect(html).toContain('data-plugin-delete-feedback-detail="backend-message"');
+ expect(html).toContain('删除失败!');
+ expect(html).toContain('backend refused plugin delete');
+ });
+
+ it('renders Angular plugin enable failure title with backend detail', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ actionError="修改失败!"
+ actionMeta="backend refused plugin toggle"
+ actionKind="enable"
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-enable-feedback="angular-edit-fail"');
+ expect(html).toContain('data-plugin-enable-failure="angular-edit-fail-notification"');
+ expect(html).toContain('data-plugin-enable-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-enable-feedback-title="common.notify.edit-fail"');
+ expect(html).toContain('data-plugin-enable-feedback-detail="backend-message"');
+ expect(html).toContain('修改失败!');
+ expect(html).toContain('backend refused plugin toggle');
+ });
+
+ it('renders Angular plugin parameter failure title with backend detail', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [],
+ totalElements: 0,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ actionError="backend rejected plugin params"
+ actionKind="params"
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-param-feedback="angular-edit-fail"');
+ expect(html).toContain('data-plugin-param-failure="angular-edit-fail-notification"');
+ expect(html).toContain('data-plugin-param-failure-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-param-feedback-title="common.notify.edit-fail"');
+ expect(html).toContain('data-plugin-param-feedback-detail="backend-message"');
+ expect(html).toContain('修改失败!');
+ expect(html).toContain('backend rejected plugin params');
+ });
+
+ it('renders Angular plugin parameter define modal and save feedback contracts', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{
+ list: {
+ content: [
+ {
+ id: 31,
+ name: 'webhook',
+ enableStatus: true,
+ items: [{ type: 'POST_ALERT' }],
+ paramCount: 2
+ }
+ ],
+ totalElements: 1,
+ pageIndex: 0,
+ pageSize: 8
+ }
+ }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ paramDraft={{
+ plugin: { id: 31, name: 'webhook', enableStatus: true } as any,
+ paramDefines: [
+ { field: 'endpoint', type: 'text', name: '端点', required: true },
+ { field: 'authType', type: 'radio', name: '认证类型', options: [{ label: 'Basic', value: 'basic' }, { label: 'Bearer', value: 'bearer' }] },
+ { field: 'script', type: 'textarea', name: '脚本' },
+ { field: 'payload', type: 'json', name: 'JSON' },
+ { field: 'headers', type: 'key-value', name: '请求头' },
+ { field: 'labels', type: 'labels', name: '标签' },
+ { field: 'selector', type: 'label-selector', name: '标签选择器' },
+ { field: 'metrics', type: 'metrics-field', name: '指标字段' },
+ { field: 'recipients', type: 'array', name: '收件人' }
+ ],
+ params: {
+ endpoint: { pluginMetadataId: 31, type: 1, field: 'endpoint', paramValue: 'https://hooks.example' },
+ authType: { pluginMetadataId: 31, type: 2, field: 'authType', paramValue: 'basic' },
+ script: { pluginMetadataId: 31, type: 2, field: 'script', paramValue: 'line one\nline two' },
+ payload: { pluginMetadataId: 31, type: 3, field: 'payload', paramValue: '{"ok":true}' },
+ headers: { pluginMetadataId: 31, type: 2, field: 'headers', paramValue: '{"Authorization":"Bearer token"}' },
+ labels: { pluginMetadataId: 31, type: 2, field: 'labels', paramValue: { env: 'prod' } },
+ selector: { pluginMetadataId: 31, type: 2, field: 'selector', paramValue: { service: 'checkout', severity: 'critical' } },
+ metrics: { pluginMetadataId: 31, type: 2, field: 'metrics', paramValue: [{ field: 'usage', unit: '%', type: 'number' }] },
+ recipients: { pluginMetadataId: 31, type: 2, field: 'recipients', paramValue: 'alpha,beta' }
+ }
+ }}
+ actionMessage="Edit Success!"
+ actionKind="params"
+ isParamPending
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onParamChange={() => {}}
+ onCloseParamDialog={() => {}}
+ onSaveParamDialog={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-param-edit-contract="angular-params-define-modal"');
+ expect(html).toContain('data-plugin-param-action-visibility-contract="angular-paramcount-zero-only"');
+ expect(html).toContain('data-plugin-param-action-visibility-owner="plugin-view-model"');
+ expect(html).toContain('data-plugin-param-save-contract="angular-params-post"');
+ expect(html).toContain('data-plugin-param-payload-contract="angular-object-values-payload"');
+ expect(html).toContain('data-plugin-param-save-lifecycle-contract="angular-close-success-keep-open-fail"');
+ expect(html).toContain('data-plugin-param-save-lifecycle-owner="route-state-contract"');
+ expect(html).toContain('data-plugin-param-save-loading-contract="angular-no-ok-loading"');
+ expect(html).toContain('data-plugin-param-save-loading="angular-no-ok-loading"');
+ expect(html).toContain('data-plugin-param-save-loading-owner="angular-modal-ok-contract"');
+ expect(html).toContain('data-plugin-param-save-pending="true"');
+ expect(html).toContain('data-plugin-param-payload="angular-object-values-payload"');
+ expect(html).toContain('data-plugin-param-save-lifecycle="angular-close-success-keep-open-fail"');
+ expect(html).toContain('data-plugin-param-feedback-contract="angular-edit-notify"');
+ expect(html).toContain('data-plugin-param-pending-editable-contract="angular-controls-remain-enabled"');
+ expect(html).toContain('data-plugin-param-pending-editable="angular-controls-remain-enabled"');
+ expect(html).toContain('data-plugin-param-pending-editable-owner="angular-param-modal"');
+ expect(html).toContain('data-plugin-param-empty-contract="angular-empty-params-modal"');
+ expect(html).toContain('data-plugin-param-empty-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-width-contract="angular-default-modal-width"');
+ expect(html).toContain('data-plugin-param-width="angular-default-modal-width"');
+ expect(html).toContain('data-plugin-param-width-owner="angular-nz-modal"');
+ expect(html).toContain('max-w-[min(92vw,520px)]');
+ expect(html).not.toContain('max-w-3xl');
+ expect(html).toContain('data-plugin-param-radio-contract="angular-nz-radio-group"');
+ expect(html).toContain('data-plugin-param-radio-owner="hertzbeat-ui-radio-button-group"');
+ expect(html).toContain('data-plugin-param-textarea-contract="angular-textarea-rows-8"');
+ expect(html).toContain('data-plugin-param-textarea-owner="hertzbeat-ui-textarea"');
+ expect(html).toContain('data-plugin-param-advanced-fields-contract="angular-configurable-and-multi-func"');
+ expect(html).toContain('data-plugin-param-advanced-fields-owner="hertzbeat-ui-advanced-param-controls"');
+ expect(html).toContain('data-plugin-param-configurable-field-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-configurable-field-owner="hertzbeat-ui-configurable-field"');
+ expect(html).toContain('data-plugin-param-key-value-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-key-value-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-plugin-param-labels-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-labels-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-plugin-param-label-selector-contract="angular-app-label-selector"');
+ expect(html).toContain('data-plugin-param-label-selector-owner="cold-label-selector"');
+ expect(html).toContain('data-plugin-param-metrics-field-contract="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-metrics-field-owner="hertzbeat-ui-configurable-field-editor"');
+ expect(html).toContain('data-plugin-param-array-contract="angular-app-multi-func-input"');
+ expect(html).toContain('data-plugin-param-array-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-plugin-param-field-layout-contract="angular-label-7-control-8"');
+ expect(html).toContain('data-plugin-param-field-layout-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-form-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-feedback="angular-edit-notify"');
+ expect(html).toContain('data-plugin-param-feedback-owner="hertzbeat-ui-inline-feedback"');
+ expect(html).toContain('data-plugin-param-edit-open="31"');
+ expect(html).toContain('data-plugin-param-dialog="angular-params-define-modal"');
+ expect(html).toContain('data-plugin-param-dialog-plugin="webhook"');
+ expect(html).toContain('data-plugin-param-form="angular-dynamic-form-field"');
+ expect(html).toContain('data-plugin-param-field-layout="angular-label-7-control-8"');
+ expect(html).toContain('data-hz-ui="monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-field="endpoint"');
+ expect(html).toContain('data-plugin-param-label-span="7"');
+ expect(html).toContain('data-plugin-param-control-span="8"');
+ expect(html).toContain('data-plugin-param-field-type="text"');
+ expect(html).toContain('data-plugin-param-field-required="true"');
+ expect(html).toContain('data-plugin-param-input="endpoint"');
+ expect(html).toContain('data-plugin-param-input-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-plugin-param-multi-func-input="angular-app-multi-func-input"');
+ expect(html).toContain('data-plugin-param-multi-func-type="text"');
+ expect(html).toContain('data-plugin-param-multi-func-clear="angular-allow-clear"');
+ expect(html).toContain('data-plugin-param-multi-func-clear-field="endpoint"');
+ expect(html).toContain('data-plugin-param-multi-func-clear-owner="hertzbeat-ui-input-affordance"');
+ expect(html).toContain('data-plugin-param-field="authType"');
+ expect(html).toContain('data-plugin-param-field-type="radio"');
+ expect(html).toContain('data-plugin-param-radio="angular-nz-radio-group"');
+ expect(html).toContain('data-plugin-param-radio-field="authType"');
+ 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).toContain('data-plugin-param-field="script"');
+ expect(html).toContain('data-plugin-param-field-type="textarea"');
+ expect(html).toContain('data-plugin-param-textarea="angular-textarea-rows-8"');
+ expect(html).toContain('data-plugin-param-textarea-rows="8"');
+ expect(html).toContain('rows="8"');
+ expect(html).toContain('data-plugin-param-field="payload"');
+ expect(html).toContain('data-plugin-param-input-owner="hertzbeat-ui-textarea"');
+ expect(html).toContain('data-plugin-param-field="headers"');
+ expect(html).toContain('data-plugin-param-field-type="key-value"');
+ expect(html).toContain('data-plugin-param-configurable-field="angular-app-configurable-field"');
+ expect(html).toContain('data-plugin-param-key-value-editor="headers"');
+ expect(html).toContain('data-plugin-param-key-value-owner="hertzbeat-ui-key-value-editor"');
+ 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('data-plugin-param-key-value-input="key"');
+ expect(html).toContain('data-plugin-param-key-value-input="value"');
+ expect(html).toContain('data-plugin-param-field="labels"');
+ expect(html).toContain('data-plugin-param-field-type="labels"');
+ expect(html).toContain('data-plugin-param-labels-editor="labels"');
+ expect(html).toContain('data-plugin-param-labels-owner="hertzbeat-ui-key-value-editor"');
+ expect(html).toContain('data-plugin-param-labels-input="key"');
+ expect(html).toContain('data-plugin-param-labels-input="value"');
+ expect(html).toContain('data-plugin-param-field="selector"');
+ expect(html).toContain('data-plugin-param-field-type="label-selector"');
+ expect(html).toContain('data-plugin-param-label-selector="angular-app-label-selector"');
+ expect(html).toContain('data-plugin-param-label-selector-field="selector"');
+ expect(html).toContain('data-plugin-param-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="service:checkout"');
+ expect(html).toContain('data-cold-label-selector-record-row="severity:critical"');
+ 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-cold-label-selector-value="hidden"');
+ expect(html).toContain('data-plugin-param-field="metrics"');
+ expect(html).toContain('data-plugin-param-field-type="metrics-field"');
+ expect(html).toContain('data-plugin-param-metrics-field-editor="metrics"');
+ expect(html).toContain('data-plugin-param-metrics-field-owner="hertzbeat-ui-configurable-field-editor"');
+ expect(html).toContain('data-hz-ui="configurable-field-editor"');
+ expect(html).toContain('data-hz-configurable-field-input="field"');
+ expect(html).toContain('data-hz-configurable-field-input="unit"');
+ expect(html).toContain('data-hz-configurable-field-input="type"');
+ expect(html).toContain('data-plugin-param-metrics-field-input="field"');
+ expect(html).toContain('data-plugin-param-metrics-field-input="unit"');
+ expect(html).toContain('data-plugin-param-metrics-field-input="type"');
+ expect(html).toContain('data-plugin-param-field="recipients"');
+ expect(html).toContain('data-plugin-param-field-type="array"');
+ expect(html).toContain('data-plugin-param-input="recipients"');
+ expect(html).toContain('data-plugin-param-array="angular-app-multi-func-input"');
+ expect(html).toContain('data-plugin-param-array-owner="hertzbeat-ui-input"');
+ expect(html).toContain('data-plugin-param-multi-func-type="array"');
+ expect(html).toContain('data-plugin-param-multi-func-clear-field="recipients"');
+ expect(html).toContain('data-plugin-param-save="angular-params-post"');
+ expect(html).toContain('data-plugin-param-cancel-loading="angular-no-ok-loading"');
+ expect(html).not.toContain('name="selector" disabled');
+ expect(html).toContain('Edit Success!');
+ expect(html).toContain('https://hooks.example');
+ expect(html).toContain('{"ok":true}');
+ expect(html).toContain('Authorization');
+ expect(html).toContain('Bearer token');
+ expect(html).toContain('env');
+ expect(html).toContain('prod');
+ expect(html).toContain('usage');
+ expect(html).toContain('number');
+ expect(html).toContain('alpha,beta');
+ });
+
+ it('keeps the Angular plugin params modal open with an empty field set', async () => {
+ const { PluginManageSurface } = await import('./plugin-manage-surface');
+ const t = createTranslatorMock({
+ locale: 'zh-CN',
+ overrides: {
+ 'common.no-data': '暂无数据',
+ 'plugin.edit': '编辑插件'
+ }
+ });
+
+ const html = renderToStaticMarkup(
+ <PluginManageSurface
+ t={t}
+ data={{ list: { content: [], totalElements: 0, pageIndex: 0, pageSize: 8 } }}
+ search=""
+ selectedIds={[]}
+ draftPlugin={null}
+ paramDraft={{
+ plugin: { id: 42, name: 'empty-param-plugin', enableStatus: true } as any,
+ paramDefines: [],
+ params: {}
+ }}
+ deleteTarget={null}
+ onSearchChange={() => {}}
+ onSearch={() => {}}
+ onRefresh={() => {}}
+ onNew={() => {}}
+ onDeleteSelected={() => {}}
+ onDeleteCancel={() => {}}
+ onDeleteConfirm={() => {}}
+ onSelectedIdsChange={() => {}}
+ onEditParams={() => {}}
+ onParamChange={() => {}}
+ onCloseParamDialog={() => {}}
+ onSaveParamDialog={() => {}}
+ onToggleEnabled={() => {}}
+ onDeleteOne={() => {}}
+ onDraftChange={() => {}}
+ onCloseDialog={() => {}}
+ onSaveDialog={() => {}}
+ />
+ );
+
+ expect(html).toContain('data-plugin-param-empty-contract="angular-empty-params-modal"');
+ expect(html).toContain('data-plugin-param-empty-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('data-plugin-param-dialog="angular-params-define-modal"');
+ expect(html).toContain('data-plugin-param-dialog-plugin="empty-param-plugin"');
+ expect(html).toContain('data-plugin-param-form="angular-dynamic-form-field"');
+ expect(html).toContain('data-plugin-param-empty="angular-empty-params-modal"');
+ expect(html).toContain('data-plugin-param-empty-owner="hertzbeat-ui-monitor-editor-field-grid"');
+ expect(html).toContain('暂无数据');
+ expect(html).not.toContain('data-plugin-param-field=');
});
});
diff --git a/web-next/components/pages/plugin-manage-surface.tsx b/web-next/components/pages/plugin-manage-surface.tsx
index 3beb70ac70..2ba41f981c 100644
--- a/web-next/components/pages/plugin-manage-surface.tsx
+++ b/web-next/components/pages/plugin-manage-surface.tsx
@@ -1,18 +1,27 @@
'use client';
import React from 'react';
-import { Inbox, Pencil, RefreshCw, Trash2, Upload } from 'lucide-react';
+import { Inbox, MoreHorizontal, Pencil, RefreshCw, Trash2, Upload, X } from 'lucide-react';
+import { HzConfigurableFieldEditor, HzConfirmDialog, HzFileInput, HzInlineFeedback, HzInput, HzKeyValueEditor, HzMonitorEditorFieldGrid, HzPaginationBar, HzRadioButtonGroup, HzSwitch, HzTableRowActionButton, HzTextarea } from '@hertzbeat/ui';
import { OverlayDialog } from '../workbench/overlay-dialog';
import { Button } from '../ui/button';
import { Checkbox } from '../ui/checkbox';
import { Input } from '../ui/input';
+import { LabelRecordInput } from '../ui/label-record-input';
import { SearchRow } from '../ui/search-row';
import { coldOpsCatalogVisual } from '../../lib/cold-ops-visual';
import { buildPluginTableRows } from '../../lib/plugin-manage/view-model';
-import type { PluginManagePageData, PluginUploadDraft } from '../../lib/plugin-manage/controller';
-import type { Plugin } from '../../lib/types';
+import type { PluginManagePageData, PluginParamDraft, PluginUploadDraft } from '../../lib/plugin-manage/controller';
+import type { ParamDefine, Plugin } from '../../lib/types';
type Translator = (key: string, params?: Record<string, string | number | null | undefined>) => string;
+type PluginActionTone = 'success' | 'warning' | 'critical';
+type PluginActionKind = 'enable' | 'delete' | 'upload' | 'params';
+export type PluginDeleteTarget = {
+ ids: number[];
+ label: string;
+ mode: 'single' | 'batch';
+};
type PluginManageSurfaceProps = {
t: Translator;
@@ -20,13 +29,38 @@ type PluginManageSurfaceProps = {
search: string;
selectedIds: number[];
draftPlugin: PluginUploadDraft | null;
+ isUploadDialogOpen?: boolean;
+ paramDraft?: PluginParamDraft | null;
+ actionMessage?: string | null;
+ actionError?: string | null;
+ actionMeta?: string | null;
+ actionTone?: PluginActionTone;
+ actionKind?: PluginActionKind;
+ loadError?: string | null;
+ uploadValidation?: { name?: boolean; jarFile?: boolean };
+ isUploadPending?: boolean;
+ isParamPending?: boolean;
+ deleteTarget: PluginDeleteTarget | null;
+ isLoadPending?: boolean;
+ isDeletePending?: boolean;
+ isTogglePending?: boolean;
+ optimisticEnableStatus?: Record<number, boolean>;
onSearchChange: (value: string) => void;
onSearch: () => void;
+ onSearchClear?: () => void;
onRefresh: () => void;
onNew: () => void;
onDeleteSelected: () => void;
+ onDeleteCancel: () => void;
+ onDeleteConfirm: () => void;
onSelectedIdsChange: (ids: number[]) => void;
+ pageSizeOptions?: number[];
+ onPageIndexChange?: (pageIndex: number) => void;
+ onPageSizeChange?: (pageSize: number) => void;
onEditParams: (plugin: Plugin) => void;
+ onParamChange?: (field: string, value: unknown) => void;
+ onCloseParamDialog?: () => void;
+ onSaveParamDialog?: () => void;
onToggleEnabled: (plugin: Plugin) => void;
onDeleteOne: (plugin: Plugin) => void;
onDraftChange: (patch: Partial<PluginUploadDraft>) => void;
@@ -48,19 +82,149 @@ const coldIconButtonClassName =
const coldDialogInputClassName =
'h-8 rounded-[3px] border border-[#2b3039] bg-[#101217] px-3 text-[12px] font-semibold text-[#eef2f7] outline-none placeholder:text-[#6f7788] focus:border-[#4e74f8] focus:ring-2 focus:ring-[rgba(78,116,248,0.16)]';
-function isChineseTranslator(t: Translator): boolean {
- return t('common.refresh') === '刷新' || t('menu.advanced.plugins') === '插件管理';
-}
+const pluginActionMenuRootClassName = 'group relative z-[9999] inline-block overflow-visible open:z-[9999]';
+const pluginActionMenuPanelBaseClassName =
+ 'mt-2 z-[9999] min-w-[132px] rounded-[3px] border border-[#2b3039] bg-[#101217] p-1 shadow-[0_18px_42px_rgba(0,0,0,0.42)]';
function pluginTypeLabel(t: Translator, type?: string) {
return type ? t(`plugin.type.${type}`) : t('common.none');
}
function pluginStatusLabel(enabled: boolean, t: Translator): string {
- if (isChineseTranslator(t)) {
- return enabled ? '已启用' : '已停用';
+ return enabled ? t('common.enabled') : t('common.disabled');
+}
+
+function stringifyPluginParamValue(value: unknown): string {
+ if (value === null || value === undefined) return '';
+ return typeof value === 'string' ? value : String(value);
+}
+
+type PluginParamKeyValueRow = {
+ key: string;
+ value: string;
+};
+
+type PluginParamConfigurableRow = Record<string, string>;
+
+function parsePluginParamKeyValueRows(value: unknown): PluginParamKeyValueRow[] {
+ 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') {
+ const entries = Object.entries(value as Record<string, unknown>);
+ return entries.length > 0
+ ? entries.map(([key, entryValue]) => ({ key, value: String(entryValue ?? '') }))
+ : [{ key: '', value: '' }];
}
- return enabled ? 'Enabled' : 'Disabled';
+
+ if (typeof value !== 'string') return [{ key: '', value: '' }];
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === '""') return [{ key: '', value: '' }];
+
+ try {
+ const parsed = JSON.parse(trimmed);
+ return parsePluginParamKeyValueRows(parsed);
+ } catch {
+ return [{ key: '', value: trimmed }];
+ }
+}
+
+function stringifyPluginParamKeyValueRows(rows: PluginParamKeyValueRow[]): string {
+ return JSON.stringify(
+ rows.reduce<Record<string, string>>((record, row) => {
+ const key = row.key.trim();
+ if (!key) return record;
+ record[key] = row.value.trim();
+ return record;
+ }, {})
+ );
+}
+
+function objectifyPluginParamLabelRows(rows: PluginParamKeyValueRow[]): Record<string, string> {
+ return rows.reduce<Record<string, string>>((record, row) => {
+ const key = row.key.trim();
+ const value = row.value.trim();
+ if (!key || !value) return record;
+ record[key] = value;
+ return record;
+ }, {});
+}
+
+function stringifyPluginParamLabelSelectorValue(value: unknown): string {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === '""') return '';
+ try {
+ const parsed = JSON.parse(trimmed);
+ return stringifyPluginParamLabelSelectorValue(parsed);
+ } catch {
+ return trimmed;
+ }
+ }
+
+ return parsePluginParamKeyValueRows(value)
+ .filter(row => row.key.trim())
+ .map(row => {
+ const key = row.key.trim();
+ const rowValue = row.value.trim();
+ return rowValue ? `${key}:${rowValue}` : key;
+ })
+ .join(', ');
+}
+
+function objectifyPluginParamLabelSelectorValue(value: string): Record<string, string> {
+ return value.split(',').reduce<Record<string, string>>((record, item) => {
+ const [keyPart, ...valueParts] = item.split(':');
+ const key = keyPart.trim();
+ const rowValue = valueParts.join(':').trim();
+ if (!key || !rowValue) return record;
+ record[key] = rowValue;
+ return record;
+ }, {});
+}
+
+function parsePluginParamConfigurableRows(value: unknown, keys: string[]): PluginParamConfigurableRow[] {
+ 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 : [{}];
+ }
+ return [{}];
+}
+
+function isPluginParamTextarea(define: ParamDefine): boolean {
+ return define.type === 'json' || define.type === 'textarea' || define.type === 'textarea-limit';
+}
+
+function isPluginParamMultiFuncInput(define: ParamDefine): boolean {
+ return define.type === 'text' || define.type === 'string' || define.type === 'array' || define.type === 'password';
+}
+
+function getPluginParamTextareaRows(define: ParamDefine): 4 | 8 {
+ return define.type === 'json' || define.type === 'textarea' ? 8 : 4;
+}
+
+function buildPluginParamRadioOptions(define: ParamDefine) {
+ return (define.options ?? []).map(option => ({
+ value: option.value ?? '',
+ label: typeof option.label === 'string' ? option.label : option.value ?? ''
+ }));
}
function PluginSwitch({
@@ -99,13 +263,38 @@ export function PluginManageSurface({
search,
selectedIds,
draftPlugin,
+ isUploadDialogOpen,
+ paramDraft,
+ actionMessage,
+ actionError,
+ actionMeta,
+ actionTone,
+ actionKind,
+ loadError,
+ uploadValidation,
+ isUploadPending,
+ isParamPending,
+ deleteTarget,
+ isLoadPending,
+ isDeletePending,
+ isTogglePending,
+ optimisticEnableStatus,
onSearchChange,
onSearch,
+ onSearchClear,
onRefresh,
onNew,
onDeleteSelected,
+ onDeleteCancel,
+ onDeleteConfirm,
onSelectedIdsChange,
+ pageSizeOptions = [8, 15, 25],
+ onPageIndexChange,
+ onPageSizeChange,
onEditParams,
+ onParamChange,
+ onCloseParamDialog,
+ onSaveParamDialog,
onToggleEnabled,
onDeleteOne,
onDraftChange,
@@ -114,8 +303,53 @@ export function PluginManageSurface({
}: PluginManageSurfaceProps) {
const resolvedSelectedIds = selectedIds ?? [];
const plugins = data.list.content ?? [];
- const rows = buildPluginTableRows(plugins);
+ const rows = buildPluginTableRows(plugins, t);
+ const currentPageIndex = Math.max(0, data.list.pageIndex ?? 0);
+ const currentPageSize = Math.max(1, data.list.pageSize ?? pageSizeOptions[0] ?? 8);
+ const totalElements = data.list.totalElements || 0;
+ const totalPages = Math.max(1, Math.ceil(totalElements / currentPageSize));
+ const currentPage = Math.min(currentPageIndex + 1, totalPages);
+ const pageStart = totalElements === 0 || rows.length === 0 ? 0 : currentPageIndex * currentPageSize + 1;
+ const pageEnd = totalElements === 0 ? 0 : Math.min(totalElements, currentPageIndex * currentPageSize + rows.length);
const allSelected = plugins.length > 0 && plugins.every(plugin => resolvedSelectedIds.includes(plugin.id));
+ const resolvedActionTone = actionTone ?? (actionError ? 'critical' : 'success');
+ const isUploadFailure = actionKind === 'upload' && Boolean(actionError);
+ const isParamFailure = actionKind === 'params' && Boolean(actionError);
+ const isEnableFailure = actionKind === 'enable' && Boolean(actionError);
+ const isDeleteFailure = actionKind === 'delete' && resolvedActionTone === 'critical' && Boolean(actionError);
+ const actionFeedbackTitle = isUploadFailure
+ ? t('common.notify.new-fail')
+ : isParamFailure
+ ? t('common.notify.edit-fail')
+ : isEnableFailure
+ ? t('common.notify.edit-fail')
+ : isDeleteFailure
+ ? t('common.notify.delete-fail')
+ : actionError ?? actionMessage ?? '';
+ const actionFeedbackMeta = isUploadFailure || isParamFailure || isEnableFailure || isDeleteFailure ? actionMeta ?? actionError ?? undefined : undefined;
+ const tableLoading = Boolean(isLoadPending || isDeletePending || isTogglePending);
+ const resolveEnableStatus = (plugin: Plugin) => (
+ optimisticEnableStatus && Object.prototype.hasOwnProperty.call(optimisticEnableStatus, plugin.id)
+ ? Boolean(optimisticEnableStatus[plugin.id])
+ : Boolean(plugin.enableStatus)
+ );
+ const pluginJarInputRef = React.useRef<HTMLInputElement>(null);
+ const [isToolbarActionMenuOpen, setToolbarActionMenuOpen] = React.useState(false);
+ const [openRowActionMenuId, setOpenRowActionMenuId] = React.useState<string | null>(null);
+ const paginationSummary = t('setting.plugins.pagination.summary', {
+ page: currentPage,
+ totalPages,
+ from: pageStart,
+ to: pageEnd,
+ total: totalElements
+ });
+
+ function handlePageJumpChange(value: string) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) return;
+ const nextPageIndex = Math.min(Math.max(parsed, 1), totalPages) - 1;
+ onPageIndexChange?.(nextPageIndex);
+ }
return (
<>
@@ -128,159 +362,520 @@ export function PluginManageSurface({
<section className={coldPluginVisual.layout.pageSection}>
<div className="mx-auto max-w-[1480px]">
<div className="mb-5">
- <div data-plugin-header="cold-compact-header" className={coldPluginVisual.panel.hero}>
+ <div
+ data-plugin-header="cold-compact-header"
+ data-plugin-action-menu-clipping="none"
+ className={`${coldPluginVisual.panel.hero} z-[80] overflow-visible`}
+ style={{ overflow: 'visible' }}
+ >
<div className="max-w-[820px]">
<h1 className="text-[30px] font-semibold leading-tight text-[#f5f7fb]">
{t('menu.advanced.plugins')}
</h1>
<p className="mt-4 max-w-[780px] text-[13px] leading-6 text-[#a9b0bb]">
- 管理自定义插件、执行类型和启用状态,保持上传、筛选、批量删除和参数编辑在同一个紧凑工作面中完成。
+ {t('setting.plugins.copy')}
</p>
<div data-plugin-command-row="standard-equal-buttons" className={coldPluginVisual.button.row}>
<Button size="sm" variant="default" className={coldButtonClassName} onClick={onRefresh}>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t('common.refresh')}
</Button>
- <Button size="sm" variant="default" className={coldPrimaryButtonClassName} onClick={onNew}>
- <Upload className="h-3.5 w-3.5" aria-hidden="true" />
- {t('plugin.upload')}
- </Button>
<Button
size="sm"
variant="default"
- className={coldButtonClassName}
- onClick={onDeleteSelected}
- disabled={resolvedSelectedIds.length === 0}
+ className={coldPrimaryButtonClassName}
+ onClick={onNew}
+ data-plugin-upload-open="angular-upload-modal"
>
- <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
- {t('plugin.delete')}
+ <Upload className="h-3.5 w-3.5" aria-hidden="true" />
+ {t('plugin.upload')}
</Button>
+ <div
+ className={pluginActionMenuRootClassName}
+ data-plugin-toolbar-delete-menu="angular-toolbar-ellipsis-delete"
+ data-plugin-toolbar-delete-menu-contract="angular-toolbar-ellipsis-delete"
+ data-plugin-toolbar-delete-menu-layer="overlay-visible-above-panel"
+ data-plugin-toolbar-delete-menu-clearance="floating-overlay-no-panel-crop"
+ data-plugin-toolbar-delete-menu-open={isToolbarActionMenuOpen ? 'true' : 'false'}
+ >
+ <button
+ type="button"
+ aria-expanded={isToolbarActionMenuOpen}
+ aria-label={t('common.edit')}
+ title={t('common.edit')}
+ className={`${coldIconButtonClassName} inline-flex cursor-pointer list-none items-center justify-center [&::-webkit-details-marker]:hidden`}
+ onClick={() => setToolbarActionMenuOpen(open => !open)}
+ data-plugin-toolbar-delete-menu-trigger="angular-toolbar-ellipsis-delete"
+ >
+ <MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
+ <span className="sr-only">{t('common.edit')}</span>
+ </button>
+ <div
+ role="menu"
+ hidden={!isToolbarActionMenuOpen}
+ className={`${pluginActionMenuPanelBaseClassName} left-0`}
+ data-plugin-toolbar-delete-menu-panel="angular-toolbar-ellipsis-delete"
+ data-plugin-toolbar-delete-menu-layer-panel="overlay-visible-above-panel"
+ data-plugin-toolbar-delete-menu-clearance-panel="floating-overlay-no-panel-crop"
+ data-plugin-toolbar-delete-menu-owner="hertzbeat-ui-table-row-action-button"
+ >
+ <HzTableRowActionButton
+ width="root-span"
+ intent="ghost"
+ onClick={() => {
+ setToolbarActionMenuOpen(false);
+ onDeleteSelected();
+ }}
+ data-plugin-delete-selected="angular-batch-delete-entry"
+ data-plugin-delete-selected-owner="hertzbeat-ui-table-row-action-button"
+ className="w-full text-[#fecaca] hover:text-white"
+ >
+ <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
+ <span className="truncate">{t('plugin.delete')}</span>
+ </HzTableRowActionButton>
+ </div>
+ </div>
</div>
</div>
</div>
</div>
- <div data-plugin-admin-layout="full-width-admin-list" className="space-y-5">
+ <div
+ data-plugin-admin-layout="full-width-admin-list"
+ data-plugin-enable-feedback-contract="angular-edit-notify"
+ data-plugin-enable-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-enable-optimistic-contract="angular-toggle-mutates-row-before-put"
+ data-plugin-enable-optimistic-owner="route-state-contract"
+ data-plugin-table-loading-contract="angular-load-plugins-table"
+ data-plugin-table-loading-scope="load-search-refresh-pagination-mutation"
+ data-plugin-load-failure-contract="angular-console-only-shell"
+ data-plugin-load-failure-owner="plugin-route-controller"
+ data-plugin-load-failure={loadError ? 'angular-console-only-shell' : 'none'}
+ data-plugin-toggle-loading-contract="angular-nz-table-loading"
+ data-plugin-toggle-loading-contract-owner="angular-nz-table-loading"
+ data-plugin-selection-reset-contract="angular-load-clears-selection"
+ data-plugin-selection-reset-owner="route-state-contract"
+ data-plugin-query-param-order-contract="angular-page-index-size-search"
+ data-plugin-query-param-order-owner="plugin-query-state"
+ data-plugin-delete-warning-contract="angular-no-select-warning"
+ data-plugin-delete-confirm-contract="angular-modal-confirm"
+ data-plugin-delete-confirm-contract-owner="hertzbeat-ui-confirm-dialog"
+ data-plugin-delete-feedback-contract="angular-delete-notify"
+ data-plugin-delete-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-delete-failure-contract="angular-delete-fail-notification"
+ data-plugin-delete-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-enable-failure-contract="angular-edit-fail-notification"
+ data-plugin-enable-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-delete-query-contract="angular-repeated-ids-query"
+ data-plugin-delete-query-owner="route-mutation-contract"
+ data-plugin-delete-page-clamp-contract="angular-update-page-index"
+ data-plugin-table-columns-contract="angular-five-column-edit-actions"
+ data-plugin-table-columns-owner="angular-nz-table"
+ data-plugin-table-stable-height-contract="viewport-fill-on-empty-or-short"
+ data-plugin-table-stable-height-owner="route-layout-contract"
+ data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"
+ data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"
+ data-plugin-upload-validation-contract="angular-required-before-submit"
+ data-plugin-upload-invalid-submit-contract="angular-mark-dirty-keep-open"
+ data-plugin-upload-invalid-submit-owner="route-validation-contract"
+ data-plugin-upload-loading-contract="angular-nz-ok-loading"
+ data-plugin-upload-payload-contract="angular-form-data"
+ data-plugin-upload-name-payload-contract="angular-raw-name-no-trim"
+ data-plugin-upload-name-payload-owner="plugin-manage-controller"
+ data-plugin-upload-save-lifecycle-contract="angular-close-success-keep-open-fail"
+ data-plugin-upload-save-lifecycle-owner="route-state-contract"
+ data-plugin-upload-success-reset-contract="angular-reset-form-after-success"
+ data-plugin-upload-success-reset-owner="route-state-contract"
+ data-plugin-upload-cancel-contract="angular-cancel-preserves-form"
+ data-plugin-upload-cancel-contract-owner="route-state-contract"
+ data-plugin-upload-cancel-pending-contract="angular-cancel-allowed-during-ok-loading"
+ data-plugin-upload-cancel-pending-owner="angular-nz-modal"
+ data-plugin-upload-mask-closable-contract="angular-mask-closable-false"
+ data-plugin-upload-width-contract="angular-width-30-percent"
+ data-plugin-upload-modal-owner="angular-nz-modal"
+ data-plugin-upload-field-layout-contract="angular-label-8-control-14"
+ data-plugin-upload-field-layout-owner="route-form-field-grid"
+ data-plugin-upload-status-control-contract="angular-nz-switch"
+ data-plugin-upload-status-control-owner="hertzbeat-ui-switch"
+ data-plugin-upload-file-list-contract="angular-before-upload-single-replace"
+ data-plugin-upload-file-list-owner="hertzbeat-ui-file-input"
+ data-plugin-upload-file-remove-contract="angular-nz-remove-clears-jar"
+ data-plugin-upload-file-remove-owner="hertzbeat-ui-file-input"
+ data-plugin-upload-feedback-contract="angular-new-notify"
+ data-plugin-upload-feedback-contract-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-upload-failure-contract="angular-new-fail-notification"
+ data-plugin-upload-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-param-edit-contract="angular-params-define-modal"
+ data-plugin-param-action-visibility-contract="angular-paramcount-zero-only"
+ data-plugin-param-action-visibility-owner="plugin-view-model"
+ data-plugin-param-mask-closable-contract="angular-mask-closable-false"
+ data-plugin-param-modal-owner="angular-nz-modal"
+ data-plugin-param-save-contract="angular-params-post"
+ data-plugin-param-payload-contract="angular-object-values-payload"
+ data-plugin-param-save-lifecycle-contract="angular-close-success-keep-open-fail"
+ data-plugin-param-save-lifecycle-owner="route-state-contract"
+ data-plugin-param-save-loading-contract="angular-no-ok-loading"
+ data-plugin-param-save-loading-owner="angular-modal-ok-contract"
+ data-plugin-param-feedback-contract="angular-edit-notify"
+ data-plugin-param-failure-contract="angular-edit-fail-notification"
+ data-plugin-param-failure-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-param-empty-contract="angular-empty-params-modal"
+ data-plugin-param-empty-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-plugin-param-pending-editable-contract="angular-controls-remain-enabled"
+ data-plugin-param-pending-editable-owner="angular-param-modal"
+ data-plugin-param-width-contract="angular-default-modal-width"
+ data-plugin-param-width-owner="angular-nz-modal"
+ data-plugin-param-radio-contract="angular-nz-radio-group"
+ data-plugin-param-radio-owner="hertzbeat-ui-radio-button-group"
+ data-plugin-param-textarea-contract="angular-textarea-rows-8"
+ data-plugin-param-textarea-owner="hertzbeat-ui-textarea"
+ data-plugin-param-advanced-fields-contract="angular-configurable-and-multi-func"
+ data-plugin-param-advanced-fields-owner="hertzbeat-ui-advanced-param-controls"
+ data-plugin-param-configurable-field-contract="angular-app-configurable-field"
+ data-plugin-param-configurable-field-owner="hertzbeat-ui-configurable-field"
+ data-plugin-param-key-value-contract="angular-app-configurable-field"
+ data-plugin-param-key-value-owner="hertzbeat-ui-key-value-editor"
+ data-plugin-param-labels-contract="angular-app-configurable-field"
+ data-plugin-param-labels-owner="hertzbeat-ui-key-value-editor"
+ data-plugin-param-label-selector-contract="angular-app-label-selector"
+ data-plugin-param-label-selector-owner="cold-label-selector"
+ data-plugin-param-metrics-field-contract="angular-app-configurable-field"
+ data-plugin-param-metrics-field-owner="hertzbeat-ui-configurable-field-editor"
+ data-plugin-param-array-contract="angular-app-multi-func-input"
+ data-plugin-param-array-owner="hertzbeat-ui-input"
+ data-plugin-param-field-layout-contract="angular-label-7-control-8"
+ data-plugin-param-field-layout-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-plugin-param-form-owner="hertzbeat-ui-monitor-editor-field-grid"
+ className="space-y-5"
+ >
<section className="min-w-0">
<SearchRow
data-plugin-manage-toolbar="cold-table-toolbar"
data-plugin-manage-search-owner="shared-search-row"
+ data-plugin-search-submit-contract="angular-enter-and-clear"
+ data-plugin-search-clear-contract="angular-cleared-load"
+ data-plugin-search-clear-owner="shared-search-row"
value={search}
placeholder={t('plugin.search')}
searchLabel={t('common.search')}
+ clearLabel={t('common.clear')}
inputWidthClassName="w-[360px]"
onValueChange={onSearchChange}
onSearch={onSearch}
+ onClear={onSearchClear}
trailingActions={
<div className="text-[12px] font-semibold text-[#858d9a]">
- 已选择 <span className="text-[#dbe4f0]">{resolvedSelectedIds.length}</span>
+ {t('setting.plugins.selected-count', { count: resolvedSelectedIds.length })}
</div>
}
/>
+ {actionFeedbackTitle ? (
+ <HzInlineFeedback
+ tone={resolvedActionTone}
+ title={actionFeedbackTitle}
+ meta={actionFeedbackMeta}
+ variant="embedded"
+ data-plugin-action-feedback={resolvedActionTone}
+ data-plugin-enable-feedback={
+ actionKind === 'enable'
+ ? resolvedActionTone === 'critical'
+ ? 'angular-edit-fail'
+ : 'angular-edit-notify'
+ : undefined
+ }
+ data-plugin-enable-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-enable-failure={isEnableFailure ? 'angular-edit-fail-notification' : undefined}
+ data-plugin-enable-failure-owner={isEnableFailure ? 'hertzbeat-ui-inline-feedback' : undefined}
+ data-plugin-enable-feedback-title={
+ actionKind === 'enable'
+ ? resolvedActionTone === 'critical'
+ ? 'common.notify.edit-fail'
+ : 'common.notify.edit-success'
+ : undefined
+ }
+ data-plugin-enable-feedback-detail={isEnableFailure ? 'backend-message' : undefined}
+ data-plugin-delete-feedback={
+ actionKind === 'delete'
+ ? resolvedActionTone === 'warning'
+ ? 'angular-no-select-warning'
+ : resolvedActionTone === 'critical'
+ ? 'angular-delete-fail'
+ : 'angular-delete-notify'
+ : undefined
+ }
+ data-plugin-delete-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-delete-failure={isDeleteFailure ? 'angular-delete-fail-notification' : undefined}
+ data-plugin-delete-failure-owner={isDeleteFailure ? 'hertzbeat-ui-inline-feedback' : undefined}
+ data-plugin-delete-feedback-title={
+ actionKind === 'delete'
+ ? resolvedActionTone === 'critical'
+ ? 'common.notify.delete-fail'
+ : resolvedActionTone === 'warning'
+ ? 'common.notify.no-select-delete'
+ : 'common.notify.delete-success'
+ : undefined
+ }
+ data-plugin-delete-feedback-detail={isDeleteFailure ? 'backend-message' : undefined}
+ data-plugin-upload-feedback={
+ actionKind === 'upload'
+ ? resolvedActionTone === 'critical'
+ ? 'angular-new-fail'
+ : 'angular-new-notify'
+ : undefined
+ }
+ data-plugin-upload-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-upload-feedback-title={
+ actionKind === 'upload'
+ ? resolvedActionTone === 'critical'
+ ? 'common.notify.new-fail'
+ : 'common.notify.new-success'
+ : undefined
+ }
+ data-plugin-upload-feedback-detail={
+ actionKind === 'upload' && resolvedActionTone === 'critical' ? 'backend-message' : undefined
+ }
+ data-plugin-param-feedback={
+ actionKind === 'params'
+ ? resolvedActionTone === 'critical'
+ ? 'angular-edit-fail'
+ : 'angular-edit-notify'
+ : undefined
+ }
+ data-plugin-param-feedback-owner="hertzbeat-ui-inline-feedback"
+ data-plugin-param-failure={isParamFailure ? 'angular-edit-fail-notification' : undefined}
+ data-plugin-param-failure-owner={
+ isParamFailure ? 'hertzbeat-ui-inline-feedback' : undefined
+ }
+ data-plugin-param-feedback-title={
+ actionKind === 'params'
+ ? resolvedActionTone === 'critical'
+ ? 'common.notify.edit-fail'
+ : 'common.notify.edit-success'
+ : undefined
+ }
+ data-plugin-param-feedback-detail={isParamFailure ? 'backend-message' : undefined}
+ />
+ ) : null}
<div
data-plugin-manage-table-shell="cold-dense-table"
- className="overflow-hidden rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_20px_56px_rgba(0,0,0,0.32)]"
+ data-plugin-table-stable-height="viewport-fill-on-empty-or-short"
+ data-plugin-table-stable-height-owner="route-layout-contract"
+ data-plugin-table-loading={tableLoading ? 'true' : 'false'}
+ data-plugin-table-loading-owner="angular-nz-table-loading"
+ aria-busy={tableLoading ? 'true' : 'false'}
+ className="flex min-h-[360px] flex-col overflow-visible rounded-[4px] border border-[#2b3039] bg-[#0b0c0e] shadow-[0_20px_56px_rgba(0,0,0,0.32)] lg:min-h-[calc(100vh-390px)]"
>
- <table data-plugin-manage-table="cold-plugin-table" className="w-full table-fixed border-collapse text-left text-[12px] text-[#a9b0bb]">
- <thead className="border-b border-[#252b34] bg-[#101217] text-[11px] font-semibold uppercase tracking-[0.12em] text-[#7e8494]">
- <tr>
- <th className="w-[44px] px-3 py-2.5">
- <Checkbox
- data-plugin-select-all="cold-checkbox"
- aria-label={t('common.select')}
- containerClassName="min-h-0"
- checked={allSelected}
- disabled={plugins.length === 0}
- onChange={event => {
- onSelectedIdsChange(event.target.checked ? plugins.map(plugin => plugin.id) : []);
- }}
- />
- </th>
- <th className="w-[24%] px-3 py-2.5">{t('plugin.name')}</th>
- <th className="w-[28%] px-3 py-2.5">{t('plugin.type')}</th>
- <th className="w-[14%] px-3 py-2.5">{t('plugin.status')}</th>
- <th className="w-[12%] px-3 py-2.5">参数</th>
- <th className="w-[96px] px-3 py-2.5">{t('common.edit')}</th>
- </tr>
- </thead>
- <tbody>
- {rows.length > 0 ? rows.map((row, index) => {
- const original = plugins[index];
- const checked = resolvedSelectedIds.includes(original.id);
- return (
- <tr key={row.key} data-plugin-row={row.key} className="border-t border-[#252b34] bg-[#0b0c0e] transition hover:bg-[#111721]">
- <td className="px-3 py-2.5">
- <Checkbox
- aria-label={t('common.select')}
- data-plugin-row-select={String(original.id)}
- containerClassName="min-h-0"
- checked={checked}
- onChange={event => {
- onSelectedIdsChange(
- event.target.checked
- ? [...resolvedSelectedIds, original.id]
- : resolvedSelectedIds.filter(value => value !== original.id)
- );
- }}
- />
- </td>
- <td className="px-3 py-2.5 font-semibold text-[#eef2f7]">{row.name}</td>
- <td className="px-3 py-2.5">
- <div className="flex flex-wrap gap-1.5">
- {row.typeLabels.length > 0 ? row.typeLabels.map(typeLabel => (
- <span key={`${row.key}-${typeLabel}`} className="rounded-[3px] border border-[#303743] bg-[#101217] px-2 py-0.5 text-[11px] leading-4 text-[#cbd5e1]">
- {pluginTypeLabel(t, typeLabel)}
- </span>
- )) : <span className="text-[#6f7681]">-</span>}
- </div>
- </td>
- <td className="px-3 py-2.5">
- <div className="flex items-center gap-2">
- <PluginSwitch
- checked={Boolean(original.enableStatus)}
- label={pluginStatusLabel(Boolean(original.enableStatus), t)}
- onChange={() => onToggleEnabled(original)}
+ <div className="min-h-0 flex-1 bg-[#0b0c0e]" data-plugin-table-fill-stage="viewport-fill-on-empty-or-short">
+ <table
+ data-plugin-manage-table="cold-plugin-table"
+ data-plugin-table-columns="angular-five-column-edit-actions"
+ data-plugin-table-columns-owner="angular-nz-table"
+ className="w-full table-fixed border-collapse text-left text-[12px] text-[#a9b0bb]"
+ >
+ <thead className="border-b border-[#252b34] bg-[#101217] text-[11px] font-semibold uppercase tracking-[0.12em] text-[#7e8494]">
+ <tr>
+ <th className="w-[44px] px-3 py-2.5">
+ <Checkbox
+ data-plugin-select-all="cold-checkbox"
+ aria-label={t('common.select')}
+ containerClassName="min-h-0"
+ checked={allSelected}
+ disabled={plugins.length === 0}
+ onChange={event => {
+ onSelectedIdsChange(event.target.checked ? plugins.map(plugin => plugin.id) : []);
+ }}
+ />
+ </th>
+ <th className="w-[26%] px-3 py-2.5">{t('plugin.name')}</th>
+ <th className="w-[34%] px-3 py-2.5">{t('plugin.type')}</th>
+ <th className="w-[16%] px-3 py-2.5">{t('plugin.status')}</th>
+ <th className="w-[96px] px-3 py-2.5">{t('common.edit')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rows.length > 0 ? rows.map((row, index) => {
+ const original = plugins[index];
+ const checked = resolvedSelectedIds.includes(original.id);
+ const hasOptimisticEnableStatus = Boolean(
+ optimisticEnableStatus && Object.prototype.hasOwnProperty.call(optimisticEnableStatus, original.id)
+ );
+ const displayEnableStatus = resolveEnableStatus(original);
+ const rowActionMenuId = String(original.id);
+ const isRowActionMenuOpen = openRowActionMenuId === rowActionMenuId;
+ const actionLabelParams = { name: row.name };
+ const editParamsLabel = t('setting.plugins.action.params-aria', actionLabelParams);
+ const deleteLabel = t('setting.plugins.action.delete-aria', actionLabelParams);
+ return (
+ <tr key={row.key} data-plugin-row={row.key} className="border-t border-[#252b34] bg-[#0b0c0e] transition hover:bg-[#111721]">
+ <td className="px-3 py-2.5">
+ <Checkbox
+ aria-label={t('common.select')}
+ data-plugin-row-select={String(original.id)}
+ containerClassName="min-h-0"
+ checked={checked}
+ onChange={event => {
+ onSelectedIdsChange(
+ event.target.checked
+ ? [...resolvedSelectedIds, original.id]
+ : resolvedSelectedIds.filter(value => value !== original.id)
+ );
+ }}
/>
- <span className="text-[11px] font-semibold text-[#cbd5e1]">
- {pluginStatusLabel(Boolean(original.enableStatus), t)}
+ </td>
+ <td className="px-3 py-2.5 font-semibold text-[#eef2f7]">{row.name}</td>
+ <td className="px-3 py-2.5">
+ <div className="flex flex-wrap gap-1.5">
+ {row.typeLabels.length > 0 ? row.typeLabels.map(typeLabel => (
+ <span key={`${row.key}-${typeLabel}`} className="rounded-[3px] border border-[#303743] bg-[#101217] px-2 py-0.5 text-[11px] leading-4 text-[#cbd5e1]">
+ {pluginTypeLabel(t, typeLabel)}
+ </span>
+ )) : <span className="text-[#6f7681]">{t('common.none')}</span>}
+ </div>
+ </td>
+ <td className="px-3 py-2.5">
+ <div
+ className="flex items-center gap-2"
+ data-plugin-enable-optimistic-state={hasOptimisticEnableStatus ? 'overridden' : 'source'}
+ data-plugin-enable-optimistic-next-status={displayEnableStatus ? 'true' : 'false'}
+ >
+ <PluginSwitch
+ checked={displayEnableStatus}
+ label={pluginStatusLabel(displayEnableStatus, t)}
+ onChange={() => onToggleEnabled(original)}
+ />
+ <span className="text-[11px] font-semibold text-[#cbd5e1]">
+ {pluginStatusLabel(displayEnableStatus, t)}
+ </span>
+ </div>
+ </td>
+ <td className="px-3 py-2.5">
+ <div data-plugin-row-actions="angular-row-actions-contextual" data-plugin-row-actions-owner="cold-icon-actions" className="flex gap-1.5">
+ {row.canEditParams ? (
+ <Button
+ size="icon"
+ variant="default"
+ className={coldIconButtonClassName}
+ onClick={() => {
+ setOpenRowActionMenuId(null);
+ onEditParams(original);
+ }}
+ aria-label={editParamsLabel}
+ title={editParamsLabel}
+ data-plugin-param-edit-open={String(original.id)}
+ data-plugin-row-action="params"
+ data-plugin-row-action-owner="row-contextual-icon-button"
+ data-plugin-row-action-label={row.name}
+ >
+ <Pencil className="h-3.5 w-3.5" aria-hidden="true" />
+ <span className="sr-only">{editParamsLabel}</span>
+ </Button>
+ ) : null}
+ <div
+ className={pluginActionMenuRootClassName}
+ data-plugin-row-delete-menu={rowActionMenuId}
+ data-plugin-row-delete-menu-contract="angular-ellipsis-dropdown-delete"
+ data-plugin-row-delete-menu-layer="overlay-visible-above-panel"
+ data-plugin-row-delete-menu-clearance="floating-overlay-no-panel-crop"
+ data-plugin-row-delete-menu-open={isRowActionMenuOpen ? 'true' : 'false'}
+ >
+ <button
+ type="button"
+ aria-expanded={isRowActionMenuOpen}
+ aria-label={t('common.edit')}
+ title={t('common.edit')}
+ className={`${coldIconButtonClassName} inline-flex cursor-pointer list-none items-center justify-center [&::-webkit-details-marker]:hidden`}
+ onClick={() => setOpenRowActionMenuId(current => (current === rowActionMenuId ? null : rowActionMenuId))}
+ data-plugin-row-delete-menu-trigger={rowActionMenuId}
+ data-plugin-row-action-label={row.name}
+ >
+ <MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
+ <span className="sr-only">{deleteLabel}</span>
+ </button>
+ <div
+ role="menu"
+ hidden={!isRowActionMenuOpen}
+ className={`${pluginActionMenuPanelBaseClassName} right-0`}
+ data-plugin-row-delete-menu-panel={rowActionMenuId}
+ data-plugin-row-delete-menu-layer-panel="overlay-visible-above-panel"
+ data-plugin-row-delete-menu-clearance-panel="floating-overlay-no-panel-crop"
+ data-plugin-row-delete-menu-owner="hertzbeat-ui-table-row-action-button"
+ data-plugin-row-delete-menu-panel-open={isRowActionMenuOpen ? 'true' : 'false'}
+ >
+ <HzTableRowActionButton
+ width="root-span"
+ intent="ghost"
+ onClick={() => {
+ setOpenRowActionMenuId(null);
+ onDeleteOne(original);
+ }}
+ data-plugin-delete-one={String(original.id)}
+ data-plugin-delete-one-owner="hertzbeat-ui-table-row-action-button"
+ data-plugin-row-action="delete"
+ data-plugin-row-action-label={row.name}
+ className="w-full text-[#fecaca] hover:text-white"
+ >
+ <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
+ <span className="truncate">{t('plugin.delete')}</span>
+ </HzTableRowActionButton>
+ </div>
+ </div>
+ </div>
+ </td>
+ </tr>
+ );
+ }) : (
+ <tr data-plugin-manage-empty-state="cold-table-empty" className="border-t border-[#252b34] bg-[#0b0c0e]">
+ <td colSpan={5} className="h-[320px] px-3 text-center text-[#a9b0bb]">
+ <div className="inline-flex flex-col items-center gap-2.5">
+ <span
+ data-plugin-manage-empty-icon="cold-empty-box"
+ className="inline-flex h-10 w-10 items-center justify-center rounded-[4px] border border-[#303743] bg-[#101217] text-[#cbd5e1]"
+ >
+ <Inbox className="h-5 w-5" aria-hidden="true" />
</span>
- </div>
- </td>
- <td className="px-3 py-2.5 tabular-nums">{row.paramCount}</td>
- <td className="px-3 py-2.5">
- <div data-plugin-row-actions="cold-icon-actions" className="flex gap-1.5">
- {row.canEditParams ? (
- <Button size="icon" variant="default" className={coldIconButtonClassName} onClick={() => onEditParams(original)} title={t('plugin.param.edit')}>
- <Pencil className="h-3.5 w-3.5" aria-hidden="true" />
- <span className="sr-only">{t('plugin.param.edit')}</span>
- </Button>
- ) : null}
- <Button size="icon" variant="default" className={coldIconButtonClassName} onClick={() => onDeleteOne(original)} title={t('plugin.delete')}>
- <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
- <span className="sr-only">{t('plugin.delete')}</span>
- </Button>
+ <div className="text-[13px] font-semibold text-[#eef2f7]">{t('common.no-data')}</div>
</div>
</td>
</tr>
- );
- }) : (
- <tr data-plugin-manage-empty-state="cold-table-empty" className="border-t border-[#252b34] bg-[#0b0c0e]">
- <td colSpan={6} className="h-[240px] px-3 text-center text-[#a9b0bb]">
- <div className="inline-flex flex-col items-center gap-2.5">
- <span
- data-plugin-manage-empty-icon="cold-empty-box"
- className="inline-flex h-10 w-10 items-center justify-center rounded-[4px] border border-[#303743] bg-[#101217] text-[#cbd5e1]"
- >
- <Inbox className="h-5 w-5" aria-hidden="true" />
- </span>
- <div className="text-[13px] font-semibold text-[#eef2f7]">暂无数据</div>
- </div>
- </td>
- </tr>
- )}
- </tbody>
- </table>
+ )}
+ </tbody>
+ </table>
+ </div>
+ <div
+ data-plugin-pagination="cold-dense-pagination"
+ data-plugin-pagination-owner="hertzbeat-ui-pagination-bar"
+ data-plugin-pagination-contract="angular-search-pagination"
+ >
+ <HzPaginationBar
+ summary={paginationSummary}
+ pageSizeLabel={t('setting.plugins.pagination.page-size')}
+ pageSizeValue={String(currentPageSize)}
+ pageSizeOptions={pageSizeOptions.map(value => ({ value: String(value), label: String(value) }))}
+ pageJumpLabel={t('setting.plugins.pagination.page')}
+ pageJumpValue={String(currentPage)}
+ pageJumpMax={totalPages}
+ previousLabel={t('common.previous-page')}
+ nextLabel={t('common.next-page')}
+ previousDisabled={currentPageIndex <= 0}
+ nextDisabled={currentPage >= totalPages}
+ onPrevious={() => onPageIndexChange?.(Math.max(currentPageIndex - 1, 0))}
+ onNext={() => onPageIndexChange?.(Math.min(currentPageIndex + 1, totalPages - 1))}
+ onPageSizeChange={value => onPageSizeChange?.(Number.parseInt(value, 10))}
+ onPageJumpChange={handlePageJumpChange}
+ pageJumpInputProps={
+ {
+ 'data-plugin-pagination-page-jump-owner': 'hertzbeat-ui-input'
+ } as React.ComponentProps<typeof HzPaginationBar>['pageJumpInputProps']
+ }
+ pageSizeSelectProps={
+ {
+ 'data-plugin-pagination-page-size-owner': 'hertzbeat-ui-select'
+ } as React.ComponentProps<typeof HzPaginationBar>['pageSizeSelectProps']
+ }
+ className="border-x-0"
+ />
+ </div>
</div>
</section>
</div>
@@ -289,55 +884,427 @@ export function PluginManageSurface({
</div>
<OverlayDialog
- open={Boolean(draftPlugin)}
+ open={Boolean((isUploadDialogOpen ?? Boolean(draftPlugin)) && draftPlugin)}
title={t('plugin.upload')}
onClose={onCloseDialog}
- maxWidthClassName="max-w-2xl"
+ maskClosable={false}
+ maxWidthClassName="max-w-[min(92vw,30rem)] lg:max-w-[30vw]"
+ overlayProps={{
+ 'data-plugin-upload-mask-closable': 'false',
+ 'data-plugin-upload-width': 'angular-width-30-percent',
+ 'data-plugin-upload-modal-owner': 'angular-nz-modal'
+ }}
footer={
<div className="flex flex-wrap justify-end gap-2">
- <Button size="sm" variant="default" className={coldButtonClassName} onClick={onCloseDialog}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldButtonClassName}
+ onClick={onCloseDialog}
+ data-plugin-upload-cancel="angular-cancel-preserves-form"
+ data-plugin-upload-cancel-pending="angular-cancel-allowed-during-ok-loading"
+ data-plugin-upload-cancel-pending-owner="angular-nz-modal"
+ >
{t('common.button.cancel')}
</Button>
- <Button size="sm" variant="default" className={coldPrimaryButtonClassName} onClick={onSaveDialog}>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldPrimaryButtonClassName}
+ onClick={onSaveDialog}
+ disabled={Boolean(isUploadPending)}
+ data-plugin-upload-save="angular-form-data"
+ data-plugin-upload-ok-loading={isUploadPending ? 'true' : 'false'}
+ data-plugin-upload-ok-loading-owner="angular-nz-ok-loading"
+ data-plugin-upload-save-lifecycle="angular-close-success-keep-open-fail"
+ data-plugin-upload-save-lifecycle-owner="route-state-contract"
+ aria-busy={isUploadPending ? 'true' : 'false'}
+ >
{t('common.button.save')}
</Button>
</div>
}
>
{draftPlugin ? (
- <div className="space-y-3">
- <label className="flex flex-col gap-2 text-[12px] font-semibold text-[#a9b0bb]">
- <span>{t('plugin.name')}</span>
- <Input
- className={coldDialogInputClassName}
- value={draftPlugin.name}
- onChange={event => onDraftChange({ name: event.target.value })}
- />
+ <div
+ className="space-y-3"
+ data-plugin-upload-dialog="angular-cancel-preserves-form"
+ data-plugin-upload-field-layout="angular-label-8-control-14"
+ data-plugin-upload-invalid-submit={uploadValidation?.name || uploadValidation?.jarFile ? 'angular-mark-dirty-keep-open' : undefined}
+ data-plugin-upload-invalid-submit-owner={uploadValidation?.name || uploadValidation?.jarFile ? 'route-validation-contract' : undefined}
+ >
+ <label
+ className="grid gap-2 text-[12px] font-semibold text-[#a9b0bb] sm:grid-cols-[minmax(96px,8fr)_minmax(0,14fr)] sm:items-start"
+ data-plugin-upload-field="name"
+ data-plugin-upload-field-layout="angular-label-8-control-14"
+ >
+ <span className="pt-2 text-right max-sm:text-left" data-plugin-upload-label-span="8">{t('plugin.name')}</span>
+ <span className="min-w-0" data-plugin-upload-control-span="14">
+ <Input
+ className={coldDialogInputClassName}
+ value={draftPlugin.name}
+ onChange={event => onDraftChange({ name: event.target.value })}
+ data-plugin-upload-name-input="angular-required"
+ data-plugin-upload-name-payload="angular-raw-name-no-trim"
+ data-plugin-upload-name-payload-owner="plugin-manage-controller"
+ />
+ </span>
+ {uploadValidation?.name ? (
+ <span className="text-[11px] font-semibold text-[#ffb4a8] sm:col-start-2" data-plugin-upload-validation="name-required">
+ {t('validation.required')}
+ </span>
+ ) : null}
</label>
- <label className="flex flex-col gap-2 text-[12px] font-semibold text-[#a9b0bb]">
- <span>{t('plugin.jar.file')}</span>
- <div className="flex flex-wrap items-center gap-2">
+ <label
+ className="grid gap-2 text-[12px] font-semibold text-[#a9b0bb] sm:grid-cols-[minmax(96px,8fr)_minmax(0,14fr)] sm:items-start"
+ data-plugin-upload-field="jarFile"
+ data-plugin-upload-field-layout="angular-label-8-control-14"
+ >
+ <span className="pt-2 text-right max-sm:text-left" data-plugin-upload-label-span="8">{t('plugin.jar.file')}</span>
+ <span className="flex min-w-0 flex-wrap items-center gap-2" data-plugin-upload-control-span="14">
+ <HzFileInput
+ ref={pluginJarInputRef}
+ accept=".jar"
+ aria-label={t('plugin.jar.file')}
+ data-plugin-upload-file-input="angular-jar-before-upload"
+ data-plugin-upload-file-input-owner="hertzbeat-ui-file-input"
+ data-plugin-upload-file-list="angular-before-upload-single-replace"
+ data-plugin-upload-file-list-owner="hertzbeat-ui-file-input"
+ onChange={event => {
+ const file = event.target.files?.[0] ?? null;
+ onDraftChange({
+ jarFile: file,
+ jarFileName: file?.name ?? ''
+ });
+ }}
+ />
<Input
className={`${coldDialogInputClassName} min-w-0 flex-1`}
value={draftPlugin.jarFileName}
- onChange={event => onDraftChange({ jarFileName: event.target.value })}
+ readOnly
+ placeholder={t('plugin.jar.file')}
+ data-plugin-upload-file-name="angular-jar-before-upload"
/>
- <Button size="sm" variant="default" className={coldButtonClassName} type="button">
+ <Button
+ size="sm"
+ variant="default"
+ className={coldButtonClassName}
+ type="button"
+ data-plugin-upload-file-trigger="angular-jar-before-upload"
+ onClick={() => pluginJarInputRef.current?.click()}
+ >
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t('common.file.select')}
</Button>
- </div>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldIconButtonClassName}
+ type="button"
+ aria-label={t('common.remove')}
+ disabled={!draftPlugin.jarFileName}
+ data-plugin-upload-file-remove="angular-nz-remove-clears-jar"
+ data-plugin-upload-file-remove-owner="hertzbeat-ui-file-input"
+ data-plugin-upload-file-remove-state={draftPlugin.jarFileName ? 'selected' : 'empty'}
+ onClick={() => {
+ if (pluginJarInputRef.current) {
+ pluginJarInputRef.current.value = '';
+ }
+ onDraftChange({
+ jarFile: null,
+ jarFileName: ''
+ });
+ }}
+ >
+ <X className="h-3.5 w-3.5" aria-hidden="true" />
+ </Button>
+ </span>
+ {uploadValidation?.jarFile ? (
+ <span className="text-[11px] font-semibold text-[#ffb4a8] sm:col-start-2" data-plugin-upload-validation="jar-required">
+ {t('validation.required')}
+ </span>
+ ) : null}
</label>
- <Checkbox
- data-plugin-draft-enabled-checkbox="cold-checkbox"
- checked={draftPlugin.enableStatus}
- onChange={event => onDraftChange({ enableStatus: event.target.checked })}
- label={t('plugin.status')}
- containerClassName="text-[#a9b0bb]"
- />
+ <div
+ className="grid gap-2 text-[12px] font-semibold text-[#a9b0bb] sm:grid-cols-[minmax(96px,8fr)_minmax(0,14fr)] sm:items-center"
+ data-plugin-upload-field="enableStatus"
+ data-plugin-upload-field-layout="angular-label-8-control-14"
+ data-plugin-upload-status-field="angular-nz-switch"
+ >
+ <span className="text-right max-sm:text-left" data-plugin-upload-label-span="8">{t('plugin.status')}</span>
+ <span className="min-w-0" data-plugin-upload-control-span="14">
+ <HzSwitch
+ checked={draftPlugin.enableStatus}
+ onCheckedChange={checked => onDraftChange({ enableStatus: checked })}
+ aria-label={t('plugin.status')}
+ data-plugin-upload-status-control="angular-nz-switch"
+ data-plugin-upload-status-control-owner="hertzbeat-ui-switch"
+ />
+ </span>
+ </div>
</div>
) : null}
</OverlayDialog>
+ <OverlayDialog
+ open={Boolean(paramDraft)}
+ title={t('plugin.edit')}
+ onClose={onCloseParamDialog ?? (() => {})}
+ maskClosable={false}
+ maxWidthClassName="max-w-[min(92vw,520px)]"
+ overlayProps={{
+ 'data-plugin-param-mask-closable': 'false',
+ 'data-plugin-param-modal-owner': 'angular-nz-modal',
+ 'data-plugin-param-width': 'angular-default-modal-width',
+ 'data-plugin-param-width-owner': 'angular-nz-modal'
+ }}
+ footer={
+ <div
+ className="flex flex-wrap justify-end gap-2"
+ data-plugin-param-save-loading-contract="angular-no-ok-loading"
+ data-plugin-param-save-loading="angular-no-ok-loading"
+ data-plugin-param-save-loading-owner="angular-modal-ok-contract"
+ data-plugin-param-save-pending={isParamPending ? 'true' : 'false'}
+ data-plugin-param-payload="angular-object-values-payload"
+ data-plugin-param-save-lifecycle="angular-close-success-keep-open-fail"
+ data-plugin-param-save-lifecycle-owner="route-state-contract"
+ data-plugin-param-pending-editable={isParamPending ? 'angular-controls-remain-enabled' : 'idle'}
+ data-plugin-param-pending-editable-owner="angular-param-modal"
+ >
+ <Button
+ size="sm"
+ variant="default"
+ className={coldButtonClassName}
+ onClick={onCloseParamDialog}
+ data-plugin-param-cancel-loading="angular-no-ok-loading"
+ >
+ {t('common.button.cancel')}
+ </Button>
+ <Button
+ size="sm"
+ variant="default"
+ className={coldPrimaryButtonClassName}
+ onClick={onSaveParamDialog}
+ data-plugin-param-save="angular-params-post"
+ data-plugin-param-save-loading="angular-no-ok-loading"
+ data-plugin-param-save-loading-owner="angular-modal-ok-contract"
+ >
+ {t('common.button.save')}
+ </Button>
+ </div>
+ }
+ >
+ {paramDraft ? (
+ <div
+ className="space-y-3"
+ data-plugin-param-dialog="angular-params-define-modal"
+ data-plugin-param-dialog-plugin={paramDraft.plugin.name}
+ >
+ <HzMonitorEditorFieldGrid
+ columns={1}
+ data-plugin-param-form="angular-dynamic-form-field"
+ data-plugin-param-form-owner="hertzbeat-ui-monitor-editor-field-grid"
+ data-plugin-param-field-layout="angular-label-7-control-8"
+ data-plugin-param-field-layout-owner="hertzbeat-ui-monitor-editor-field-grid"
+ >
+ {paramDraft.paramDefines.length > 0 ? paramDraft.paramDefines.map(define => {
+ const current = paramDraft.params[define.field];
+ const value = current?.paramValue;
+ const stringValue = stringifyPluginParamValue(value);
+ const label = typeof define.name === 'string' ? define.name : define.field;
+ const isMultiFuncInput = isPluginParamMultiFuncInput(define);
+ return (
+ <label
+ key={define.field}
+ className="grid gap-2 text-[12px] font-semibold text-[#a9b0bb] sm:grid-cols-[minmax(112px,7fr)_minmax(0,8fr)] sm:items-start"
+ data-plugin-param-field={define.field}
+ data-plugin-param-field-layout="angular-label-7-control-8"
+ data-plugin-param-field-type={define.type ?? 'password'}
+ data-plugin-param-field-required={define.required ? 'true' : 'false'}
+ >
+ <span className="pt-2 text-right max-sm:text-left" data-plugin-param-label-span="7">
+ {label}
+ {define.required ? <span className="ml-1 text-[#ffb4a8]">*</span> : null}
+ </span>
+ <span className="min-w-0" data-plugin-param-control-span="8">
+ {define.type === 'radio' && (define.options ?? []).length > 0 ? (
+ <HzRadioButtonGroup
+ name={define.field}
+ value={stringifyPluginParamValue(value)}
+ options={buildPluginParamRadioOptions(define)}
+ onChange={nextValue => onParamChange?.(define.field, nextValue)}
+ data-plugin-param-radio="angular-nz-radio-group"
... 4381 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org