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 3398db3c71f087c4301362fb666fea080dc3598f Merge: b84cb3a73a f82c340a16 Author: Logic <[email protected]> AuthorDate: Wed Aug 26 14:50:47 2026 +0800 Merge validated React operational workflows .../manager/controller/MonitorController.java | 14 + .../pojo/dto/MonitorInvestigationBindingInfo.java | 36 + .../MonitorInvestigationReadModelService.java | 117 ++ .../manager/controller/MonitorControllerTest.java | 44 + .../MonitorInvestigationReadModelServiceTest.java | 160 +++ .../controller/MetricsDataController.java | 36 + .../controller/MetricsDataControllerTest.java | 34 + web-app/package.json | 5 + web-app/playwright.config.ts | 41 + web-app/pnpm-lock.yaml | 57 + web-app/scripts/bundle-limits.json | 2 +- web-app/src/app/alert-route-source.test.tsx | 1 + .../app/decorative-boundary-style-contract.test.ts | 3 +- .../monitor-navigation-resource-loader.test.tsx | 17 + .../refine/monitor-navigation-resource-loader.tsx | 6 +- .../app/refine/refine-resource-registry.test.ts | 2 +- .../src/app/refine/refine-resource-registry.tsx | 12 +- web-app/src/app/refine/refine-runtime.test.tsx | 1 + web-app/src/app/refine/refine-runtime.tsx | 20 +- .../src/app/refine/session-query-runtime.test.tsx | 25 + web-app/src/app/refine/session-query-runtime.tsx | 11 +- .../settings-form-layout-style-contract.test.ts | 19 +- web-app/src/app/theme/hertzbeat-theme.test.ts | 21 +- web-app/src/assets/i18n/en-us.json | 38 + web-app/src/assets/i18n/ja-jp.json | 38 + web-app/src/assets/i18n/pt-br.json | 38 + web-app/src/assets/i18n/zh-cn.json | 38 + web-app/src/assets/i18n/zh-tw.json | 38 + web-app/src/core/auth/session-cache-boundary.ts | 17 + .../core/auth/session-foreground-revalidation.ts | 21 +- web-app/src/core/i18n/locale-catalog-shape.test.ts | 52 + .../src/features/alert/api/alert-rule-api.test.ts | 3 +- .../alert/api/alert-rule-write-proof.test.ts | 3 +- .../components/alert-center-confirmed-action.tsx | 45 - .../alert/components/alert-rule-type-chooser.tsx | 5 +- .../use-alert-group-controller-create.test.tsx | 264 +++++ .../use-alert-group-controller-delete.test.tsx | 97 ++ .../use-alert-group-controller-ownership.test.tsx | 247 +++++ .../use-alert-group-controller-test-support.ts | 167 +++ .../use-alert-group-controller-update.test.tsx | 404 +++++++ .../controller/use-alert-group-controller.test.tsx | 1034 +----------------- .../controller/use-alert-rule-editor-route.ts | 2 +- .../integration/model/alert-integration-model.ts | 4 +- .../features/alert/model/alert-rule-condition.ts | 13 - .../src/features/alert/model/alert-rule-draft.ts | 4 +- .../features/alert/model/alert-silence-types.ts | 2 +- .../model/notice-receiver-channel-validation.ts | 37 + .../notice-receiver/model/notice-receiver-model.ts | 15 +- .../auth/pages/login-page-style-contract.test.ts | 26 +- .../src/features/auth/pages/login-page.module.css | 47 +- .../explore/controller/explore-query-keys.test.ts | 2 +- .../explore/model/explore-context-model.ts | 4 + .../features/explore/model/explore-model.test.ts | 22 + .../src/features/explore/model/explore-query.ts | 12 + .../features/explore/model/explore-url-model.ts | 25 +- .../instrumentation-configure-step-props.ts | 42 + .../components/instrumentation-configure-step.tsx | 162 +-- .../instrumentation-configure.module.css | 138 +-- ...instrumentation-destination-selector.module.css | 170 +++ .../instrumentation-destination-selector.tsx | 146 ++- .../instrumentation-guide-configuration.module.css | 68 ++ .../instrumentation-guide-configuration.tsx | 148 +++ .../components/instrumentation-guide-workspace.tsx | 37 +- .../instrumentation-guided-configuration.test.tsx | 160 +++ .../instrumentation-onboarding.module.css | 33 +- .../components/instrumentation-progress.tsx | 21 +- .../components/instrumentation-shell.module.css | 55 +- .../components/instrumentation-v2-flow.test.tsx | 91 +- .../instrumentation/instrumentation-i18n.test.ts | 9 + .../instrumentation/model/instrumentation-flow.ts | 2 +- .../model/instrumentation-guided-flow.ts | 24 + .../pages/instrumentation-page.test.tsx | 5 +- .../instrumentation/pages/instrumentation-page.tsx | 140 +-- .../pages/instrumentation-stage-content.tsx | 130 +++ .../src/features/monitor/api/monitor-api.test.ts | 2 +- web-app/src/features/monitor/api/monitor-api.ts | 8 + .../monitor/api/monitor-history-path.test.ts} | 37 +- .../monitor/api/monitor-investigation-api.test.ts | 62 ++ .../monitor/api/monitor-investigation-schema.ts | 47 + .../src/features/monitor/api/monitor-metric-api.ts | 11 +- .../monitor/api/monitor-metric-layout-api.test.ts | 101 ++ .../monitor/api/monitor-metric-layout-api.ts | 17 +- .../monitor/components/monitor-list-results.tsx | 35 +- .../monitor/components/monitor-list-view.test.tsx | 15 + .../components/monitor-metric-workbench.test.tsx | 48 + .../monitor-realtime-layout-grid.test.tsx | 76 +- .../monitor-selected-history-controls.tsx | 38 +- .../components/monitor-selected-history-tray.tsx | 2 +- .../controller/monitor-metric-workbench-actions.ts | 3 + .../controller/monitor-metric-workbench-result.ts | 5 + .../monitor/controller/monitor-query-keys.ts | 1 + .../controller/use-monitor-investigation.ts | 51 + .../use-monitor-metric-layout-command-owner.ts | 103 ++ .../use-monitor-metric-layout-controller.test.tsx | 148 ++- .../use-monitor-metric-layout-controller.ts | 69 +- ...se-monitor-metric-workbench-controller.test.tsx | 48 + .../use-monitor-metric-workbench-controller.ts | 8 +- .../features/monitor/model/monitor-detail-model.ts | 3 + .../model/monitor-investigation-model.test.ts | 112 ++ .../monitor/model/monitor-investigation-model.ts | 118 ++ .../use-collector-controller-file-log.test.tsx | 186 ++++ .../use-collector-controller-list-intake.test.tsx | 337 ++++++ .../use-collector-controller-prometheus.test.tsx | 248 +++++ .../use-collector-controller-runtime.test.tsx | 235 ++++ .../use-collector-controller-test-support.ts | 224 ++++ .../controller/use-collector-controller.test.tsx | 1124 +------------------- .../monitor-definition-catalog.module.css | 2 +- ...onitor-definition-controller-authority.test.tsx | 328 ++++++ ...monitor-definition-controller-recovery.test.tsx | 412 +++++++ ...-monitor-definition-controller-routing.test.tsx | 240 +++++ ...e-monitor-definition-controller-test-support.ts | 145 +++ .../use-monitor-definition-controller.test.tsx | 1038 +----------------- .../monitor-definition-layout-contract.test.ts | 8 + .../system-config/api/public-access-config-api.ts | 2 +- .../public-access-config-section.test.tsx | 13 +- .../components/public-access-config-section.tsx | 99 +- .../pages/system-config-page.test.tsx | 9 + .../system-config/pages/system-config-page.tsx | 14 +- web-app/src/features/setup/model/setup-optional.ts | 2 +- .../components/status-org-settings-workspace.tsx | 2 +- ...status-management-controller-component.test.tsx | 371 +++++++ ...-status-management-controller-incident.test.tsx | 124 +++ ...-status-management-controller-resource.test.tsx | 354 ++++++ ...se-status-management-controller-test-support.ts | 152 +++ .../use-status-management-controller.test.tsx | 929 +--------------- .../controller/topology-query-keys.test.ts | 2 +- .../topology/runtime/topology-g6-lifecycle.test.ts | 21 + .../topology/runtime/topology-g6-lifecycle.ts | 5 + .../src/layout/shell/hertzbeat-shell.module.css | 2 +- .../layout/shell/shell-navigation-style.test.ts | 8 + .../notification-workspace-navigation.module.css | 2 +- .../notification-workspace-style-contract.test.ts | 7 +- .../query-context/query-context-model.test.ts | 4 +- .../shared/query-context/query-context-model.ts | 36 + web-app/src/shared/theme/hertzbeat-theme.ts | 24 + web-app/src/test/locale-catalog-shape.ts | 47 + .../tests/browser/production-accessibility.spec.ts | 61 ++ .../tests/browser/production-browser-support.ts | 335 ++++++ .../tests/browser/production-route-smoke.spec.ts | 244 +++++ web-app/tsconfig.node.json | 2 +- web-app/vite.config.ts | 2 +- 141 files changed, 8808 insertions(+), 4789 deletions(-) diff --cc web-app/scripts/bundle-limits.json index e99bd2eef9,eba1da0a5f..9cc392a5f1 --- a/web-app/scripts/bundle-limits.json +++ b/web-app/scripts/bundle-limits.json @@@ -3,5 -3,5 +3,5 @@@ "vendorChunkMinBytes": 512000, "vendorChunkMaxBytes": 614400, "shellGzipBytes": 460800, - "totalJavaScriptBytes": 6690000 - "totalJavaScriptBytes": 6600000 ++ "totalJavaScriptBytes": 7100000 } diff --cc web-app/src/app/alert-route-source.test.tsx index 5d37938031,d33342118b..b52f52af71 --- a/web-app/src/app/alert-route-source.test.tsx +++ b/web-app/src/app/alert-route-source.test.tsx @@@ -166,6 -173,6 +166,7 @@@ describe('Alert route ownership', () = name: 'Canonical rule', expr: 'usage > 90', template: 'Alert', ++ labelsText: 'severity:warning', period: 300, times: 3 }) diff --cc web-app/src/app/decorative-boundary-style-contract.test.ts index cbf59ea9a5,cbf59ea9a5..96518ff37c --- a/web-app/src/app/decorative-boundary-style-contract.test.ts +++ b/web-app/src/app/decorative-boundary-style-contract.test.ts @@@ -34,7 -34,7 +34,8 @@@ describe('decorative boundary style con }); it('does not frame settings forms or table wrappers with decorative rules', () => { -- expect(ruleBody(objectStoreStyles, /\.form\s*\{(?<body>[^}]*)\}/)).not.toMatch(horizontalBorder); ++ expect(ruleBody(objectStoreStyles, /\.workspace\s*\{(?<body>[^}]*)\}/)).not.toMatch(horizontalBorder); ++ expect(ruleBody(objectStoreStyles, /\.actions\s*\{(?<body>[^}]*)\}/)).not.toMatch(horizontalBorder); expect(objectStoreStyles).not.toMatch(/\.field\s*\+\s*\.field\s*\{[^}]*border-top:/s); expect(ruleBody(systemConfigStyles, /\.form\s*\{(?<body>[^}]*)\}/)).not.toMatch(horizontalBorder); expect(systemConfigStyles).not.toMatch(/\.field\s*\+\s*\.field\s*\{[^}]*border-top:/s); diff --cc web-app/src/app/settings-form-layout-style-contract.test.ts index c422a0a84e,c422a0a84e..b461e51c35 --- a/web-app/src/app/settings-form-layout-style-contract.test.ts +++ b/web-app/src/app/settings-form-layout-style-contract.test.ts @@@ -18,12 -18,12 +18,9 @@@ function ruleBody(css: string, selector } describe('settings form layout contract', () => { -- it.each([ -- ['object storage', objectStoreStyles], -- ['system settings', systemConfigStyles] -- ])('keeps the %s controls in a compact stacked rail', (_name, styles) => { -- const form = ruleBody(styles, /\.form\s*\{(?<body>[^}]*)\}/); -- const field = ruleBody(styles, /\.field\s*\{(?<body>[^}]*)\}/); ++ it('keeps system settings controls in a compact stacked rail', () => { ++ const form = ruleBody(systemConfigStyles, /\.form\s*\{(?<body>[^}]*)\}/); ++ const field = ruleBody(systemConfigStyles, /\.field\s*\{(?<body>[^}]*)\}/); expect(form).toMatch(/max-width:\s*520px/); expect(form).toMatch(/margin-inline:\s*0/); @@@ -31,6 -31,6 +28,16 @@@ expect(field).not.toMatch(/190px/); }); ++ it('keeps object storage as an adaptive provider workspace', () => { ++ const surface = ruleBody(objectStoreStyles, /\.surface\s*\{(?<body>[^}]*)\}/); ++ const workspace = ruleBody(objectStoreStyles, /\.workspace\s*\{(?<body>[^}]*)\}/); ++ const field = ruleBody(objectStoreStyles, /\.field\s*\{(?<body>[^}]*)\}/); ++ ++ expect(surface).toMatch(/width:\s*min\(100%,\s*840px\)/); ++ expect(workspace).toMatch(/grid-template-columns:\s*238px minmax\(0,\s*1fr\)/); ++ expect(field).not.toMatch(/190px/); ++ }); ++ it('aligns default form actions with the left edge of the control rail', () => { const actions = ruleBody(operationalPageStyles, /\.formActions\s*\{(?<body>[^}]*)\}/); expect(actions).toMatch(/justify-content:\s*flex-start/); diff --cc web-app/src/features/alert/api/alert-rule-api.test.ts index 52966807f8,52966807f8..e63a452847 --- a/web-app/src/features/alert/api/alert-rule-api.test.ts +++ b/web-app/src/features/alert/api/alert-rule-api.test.ts @@@ -235,7 -235,7 +235,8 @@@ describe('alert rule API', () => ...createAlertRuleDraft(), name: 'CPU high', expr: 'usage > 90', -- template: 'CPU' ++ template: 'CPU', ++ labelsText: 'severity:warning' }; vi.mocked(apiMessageGet).mockRejectedValueOnce(transportFailure()); diff --cc web-app/src/features/alert/api/alert-rule-write-proof.test.ts index 29ebe5d97b,29ebe5d97b..be27ca1b64 --- a/web-app/src/features/alert/api/alert-rule-write-proof.test.ts +++ b/web-app/src/features/alert/api/alert-rule-write-proof.test.ts @@@ -40,7 -40,7 +40,8 @@@ const expected = buildAlertRulePayload( ...createAlertRuleDraft(), name: 'New Rule', expr: 'usage > 90', -- template: 'Alert' ++ template: 'Alert', ++ labelsText: 'severity:warning' }); const matching: AlertRule = { id: 9, diff --cc web-app/src/features/alert/components/alert-center-confirmed-action.tsx index cac6a87f1c,3215783b76..0000000000 deleted file mode 100644,100644 --- a/web-app/src/features/alert/components/alert-center-confirmed-action.tsx +++ /dev/null @@@ -1,45 -1,43 +1,0 @@@ --/* -- * Licensed to the Apache Software Foundation (ASF) under one or more -- * contributor license agreements. See the NOTICE file distributed with -- * this work for additional information regarding copyright ownership. -- * The ASF licenses this file to You under the Apache License, Version 2.0. -- */ -- --import { Button, Popconfirm } from 'antd'; -- --type AlertCenterConfirmedActionProps = { - className?: string; -- confirm: string; -- confirmLabel: string; -- danger?: boolean; -- disabled: boolean; -- label: string; -- run: () => void | Promise<unknown>; -- type?: 'default' | 'link'; --}; -- --/** Centralizes confirmation semantics for destructive and status-changing alert actions. */ --export function AlertCenterConfirmedAction({ - className, -- confirm, -- confirmLabel, -- danger = false, -- disabled, -- label, -- run, -- type = 'default' --}: AlertCenterConfirmedActionProps) { -- return ( -- <Popconfirm -- title={confirm} -- okText={confirmLabel} -- disabled={disabled} -- okButtonProps={{ danger, disabled }} -- onConfirm={() => !disabled && void run()} -- > - <Button {...(className ? { className } : {})} size="small" type={type} danger={danger} disabled={disabled}> - <Button size="small" type={type} danger={danger} disabled={disabled}> -- {label} -- </Button> -- </Popconfirm> -- ); --} diff --cc web-app/src/features/alert/components/alert-rule-type-chooser.tsx index d99e21d588,0000000000..5ca4ababb9 mode 100644,000000..100644 --- a/web-app/src/features/alert/components/alert-rule-type-chooser.tsx +++ b/web-app/src/features/alert/components/alert-rule-type-chooser.tsx @@@ -1,82 -1,0 +1,79 @@@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { Button, Modal } from 'antd'; +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { AlertRuleDatasourceState, AlertRuleKind } from '../model/alert-rule-model'; +import styles from '../shared/alert-rule-editor.module.css'; +import { AlertRuleDatasourceEvidence } from './alert-rule-editor-evidence'; + - export function AlertRuleTypeChooser(props: { - datasource: AlertRuleDatasourceState; - choose: (kind: AlertRuleKind) => void; - }) { ++function AlertRuleTypeChooser(props: { datasource: AlertRuleDatasourceState; choose: (kind: AlertRuleKind) => void }) { + const { t } = useTranslation(); + const realtimeTitleId = useId(); + const realtimeDescriptionId = useId(); + const periodicTitleId = useId(); + const periodicDescriptionId = useId(); + const periodicAvailable = + props.datasource.kind === 'ready' && + (props.datasource.status.hasPromqlExecutor || props.datasource.status.hasSqlExecutor); + return ( + <section className={styles.typeChooser} aria-label={t('alertRules.typeChoice.title')}> + <Button + aria-describedby={realtimeDescriptionId} + aria-labelledby={realtimeTitleId} + className={styles.typeChoice ?? ''} + onClick={() => props.choose('realtime')} + > + <ThunderboltOutlined className={styles.typeChoiceIcon} /> + <span className={styles.typeChoiceContent}> + <strong id={realtimeTitleId}>{t('alertRules.kind.realtime')}</strong> + <small id={realtimeDescriptionId}>{t('alertRules.typeChoice.realtimeDescription')}</small> + </span> + </Button> + <Button + aria-describedby={periodicDescriptionId} + aria-labelledby={periodicTitleId} + className={styles.typeChoice ?? ''} + disabled={!periodicAvailable} + onClick={() => props.choose('periodic')} + > + <ClockCircleOutlined className={styles.typeChoiceIcon} /> + <span className={styles.typeChoiceContent}> + <strong id={periodicTitleId}>{t('alertRules.kind.periodic')}</strong> + <small id={periodicDescriptionId}> + {t( + periodicAvailable + ? 'alertRules.typeChoice.periodicDescription' + : 'alertRules.typeChoice.periodicUnavailable' + )} + </small> + </span> + </Button> + </section> + ); +} + +export function AlertRuleTypeChooserDialog(props: { + datasource: AlertRuleDatasourceState; + onCancel: () => void; + onChoose: (kind: AlertRuleKind) => void; + retry: () => unknown; +}) { + const { t } = useTranslation(); + return ( + <Modal open title={t('alertRules.typeChoice.title')} width={500} footer={null} onCancel={props.onCancel}> + <div className={styles.typeChooserDialogBody}> + <AlertRuleDatasourceEvidence state={props.datasource} retry={props.retry} /> + <AlertRuleTypeChooser + datasource={props.datasource} + choose={kind => { + props.onChoose(kind); + props.onCancel(); + }} + /> + </div> + </Modal> + ); +} diff --cc web-app/src/features/alert/controller/use-alert-rule-editor-route.ts index 91f78d7af1,09181c7b69..4c35540729 --- a/web-app/src/features/alert/controller/use-alert-rule-editor-route.ts +++ b/web-app/src/features/alert/controller/use-alert-rule-editor-route.ts @@@ -66,17 -62,6 +66,17 @@@ export function useAlertRuleEditorRoute }; } - export function parseAlertRuleKind(search: string): AlertRuleKind | null { ++function parseAlertRuleKind(search: string): AlertRuleKind | null { + const value = new URLSearchParams(search).get('kind'); + return value === 'realtime' || value === 'periodic' ? value : null; +} + +function createInitialDraft(mode: 'new' | 'edit', kind: AlertRuleKind | null) { + if (mode === 'edit' || kind === null) return null; + const draft = createAlertRuleDraft(); + return { ...draft, ...buildAlertRuleStrategyPatch(draft, kind, 'metric') }; +} + function resolveCanonicalDraft( mode: 'new' | 'edit', initialDraft: AlertRuleDraft | null, diff --cc web-app/src/features/alert/integration/model/alert-integration-model.ts index e6a0925fd0,650ca4409a..577323ced7 --- a/web-app/src/features/alert/integration/model/alert-integration-model.ts +++ b/web-app/src/features/alert/integration/model/alert-integration-model.ts @@@ -21,11 -21,7 +21,11 @@@ import { settingsPaths } from '@/shared const alertIntegrationWriterScope: AccessTokenScope = 'api-admin'; - export type AlertIntegrationReadiness = 'ready' | 'configuration_required' | 'guide_blocked'; + type AlertIntegrationReadiness = 'ready' | 'configuration_required' | 'guide_blocked'; +export type AlertIntegrationVerification = + | { status: 'unverified'; startedAt: null; verifiedAt: null } + | { status: 'waiting'; startedAt: number; verifiedAt: null } + | { status: 'verified'; startedAt: number; verifiedAt: number }; export type AlertIntegrationIconKey = | 'hertzbeat' | 'prometheus' @@@ -43,13 -39,8 +43,13 @@@ type AlertIntegrationDescriptor = readiness: AlertIntegrationReadiness; limitations: string[]; }; +export type AlertIntegrationCatalogItem = AlertIntegrationDescriptor & { + verification: AlertIntegrationVerification; +}; export type AlertIntegrationCatalog = { items: AlertIntegrationCatalogItem[] }; - export type AlertIntegrationRequiredHeaders = -export type AlertIntegrationGuide = AlertIntegrationCatalogItem & { ++type AlertIntegrationRequiredHeaders = + { Authorization: 'Bearer {token}' } | { 'X-HertzBeat-Token': '{token}' } | { Token: '{token}' }; +export type AlertIntegrationGuide = AlertIntegrationDescriptor & { method: 'POST'; ingressPath: string; payloadShape: string; diff --cc web-app/src/features/alert/model/alert-rule-condition.ts index cd71d49ef8,c7cf0eff26..4ba288311f --- a/web-app/src/features/alert/model/alert-rule-condition.ts +++ b/web-app/src/features/alert/model/alert-rule-condition.ts @@@ -93,30 -75,19 +93,17 @@@ export function serializeMetricAlertCon ); } -/** - * Returns null while a guided condition is incomplete. This lets the UI retain - * transient input without producing or discarding a writable rule. - */ -export function serializeCompleteMetricAlertCondition(group: MetricAlertConditionGroup, fields: MetricAlertField[]) { - if (group.items.length === 0) return null; - try { - return serializeMetricAlertCondition(group, fields); - } catch { - return null; - } +/** Serializes the source-visible draft, including Angular-compatible incomplete values. */ +export function serializeMetricAlertConditionAuthoring(group: MetricAlertConditionGroup, fields: MetricAlertField[]) { + return serializeMetricAlertConditionAuthoringSource( + group, + metricFieldMap(fields), + metricAlertOperatorsForType, + metricAlertConditionLimits.maximumDepth, + metricAlertConditionLimits.maximumItemsPerGroup + ); } - /** - * Returns null while a guided condition is incomplete. This lets the UI retain - * transient input without producing or discarding a writable rule. - */ - export function serializeCompleteMetricAlertCondition(group: MetricAlertConditionGroup, fields: MetricAlertField[]) { - if (group.items.length === 0) return null; - try { - return serializeMetricAlertCondition(group, fields); - } catch { - return null; - } - } - /** * Parses the structured subset conservatively. Unsupported or ambiguous input * returns null so callers can retain the source in expert mode. diff --cc web-app/src/features/alert/model/alert-rule-draft.ts index 9b58a669ed,39260b2829..4da79657c2 --- a/web-app/src/features/alert/model/alert-rule-draft.ts +++ b/web-app/src/features/alert/model/alert-rule-draft.ts @@@ -22,11 -19,6 +22,11 @@@ export type AlertRulePreviewRequest = expr: string; }; +export const alertRuleSeverities = ['emergency', 'critical', 'warning'] as const; +export const alertRuleModes = ['group', 'individual'] as const; - export type AlertRuleSeverity = (typeof alertRuleSeverities)[number]; - export type AlertRuleMode = (typeof alertRuleModes)[number]; ++type AlertRuleSeverity = (typeof alertRuleSeverities)[number]; ++type AlertRuleMode = (typeof alertRuleModes)[number]; + const strategyForType: Record<AlertRuleType, { kind: AlertRuleKind; dataType: AlertRuleDataType }> = { realtime_metric: { kind: 'realtime', dataType: 'metric' }, periodic_metric: { kind: 'periodic', dataType: 'metric' }, diff --cc web-app/src/features/alert/model/alert-silence-types.ts index 6ef27cb099,82dcdf827a..4657de5dba --- a/web-app/src/features/alert/model/alert-silence-types.ts +++ b/web-app/src/features/alert/model/alert-silence-types.ts @@@ -20,16 -20,6 +20,16 @@@ import type { PagedCollection } from '@ export type AlertSilenceQuery = { search: string; pageIndex: number; pageSize: number }; export type AlertSilenceType = 0 | 1; +type AlertSilenceScheduleWindow = { + periodStart: string; + periodEnd: string; +}; + - export type AlertSilenceScheduleMemory = { ++type AlertSilenceScheduleMemory = { + once: AlertSilenceScheduleWindow; + recurring: AlertSilenceScheduleWindow; +}; + type AlertSilenceWritableSnapshot = { matchAll: boolean; type: AlertSilenceType; diff --cc web-app/src/features/alert/notice-receiver/model/notice-receiver-channel-validation.ts index 0000000000,0000000000..215b67cf51 new file mode 100644 --- /dev/null +++ b/web-app/src/features/alert/notice-receiver/model/notice-receiver-channel-validation.ts @@@ -1,0 -1,0 +1,37 @@@ ++/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ ++ ++import type { NoticeReceiverDraft, NoticeReceiverSecretKey } from './notice-receiver-model'; ++ ++type SecretAvailable = (draft: NoticeReceiverDraft, key: NoticeReceiverSecretKey) => boolean; ++ ++export function channelValidationErrors(draft: NoticeReceiverDraft, secretAvailable: SecretAvailable) { ++ switch (draft.type) { ++ case 1: ++ return emailErrors(draft); ++ case 4: ++ case 5: ++ return phoneErrors(draft); ++ case 2: ++ return webhookAuthErrors(draft, secretAvailable); ++ case 10: ++ return weComRecipientErrors(draft); ++ default: ++ return []; ++ } ++} ++ ++function emailErrors(draft: NoticeReceiverDraft) { ++ return draft.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(draft.email) ? ['email'] : []; ++} ++ ++function phoneErrors(draft: NoticeReceiverDraft) { ++ return draft.phone && !/^(1\d{10})(,\s*1\d{10})*$/.test(draft.phone) ? ['phone'] : []; ++} ++ ++function webhookAuthErrors(draft: NoticeReceiverDraft, secretAvailable: SecretAvailable) { ++ return draft.hookAuthType !== 'None' && !secretAvailable(draft, 'hookAuthToken') ? ['hookAuthToken'] : []; ++} ++ ++function weComRecipientErrors(draft: NoticeReceiverDraft) { ++ return [draft.userId, draft.partyId, draft.tagId].some(value => value.trim()) ? [] : ['recipientTarget']; ++} diff --cc web-app/src/features/alert/notice-receiver/model/notice-receiver-model.ts index dc340fdb7d,51dbd54430..a6f99dcbc0 --- a/web-app/src/features/alert/notice-receiver/model/notice-receiver-model.ts +++ b/web-app/src/features/alert/notice-receiver/model/notice-receiver-model.ts @@@ -21,6 -21,6 +21,8 @@@ import import { compactTablePageSizes } from '@/shared/pagination'; import { readZeroBasedPage, writeZeroBasedPage } from '@/shared/query-context'; ++import { channelValidationErrors } from './notice-receiver-channel-validation'; ++ export * from './notice-receiver-catalog'; export const noticeReceiverPageSizes = compactTablePageSizes; @@@ -146,7 -146,7 +148,7 @@@ export function validateNoticeReceiverD const invalid: string[] = normalizedName && normalizedName.length <= noticeReceiverNameMaxLength ? [] : ['name']; invalid.push(...requiredFieldErrors(draft)); invalid.push(...activeValueErrors(draft)); -- invalid.push(...channelValidationErrors(draft)); ++ invalid.push(...channelValidationErrors(draft, hasSecret)); if (!hasUniqueSecrets(draft.configuredSecrets)) invalid.push('configuredSecrets'); if (!hasUniqueSecrets(draft.clearSecrets)) invalid.push('clearSecrets'); const recipientKey = draft.type === 14 ? feiShuRecipientKeys[draft.larkReceiveType] : undefined; @@@ -176,17 -176,14 +178,6 @@@ function activeValueErrors(draft: Notic return invalid; } --function channelValidationErrors(draft: NoticeReceiverDraft) { -- if (draft.type === 1 && draft.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(draft.email)) return ['email']; - if ((draft.type === 4 || draft.type === 5) && draft.phone && !/^(1\d{10})(,\s*1\d{10})*$/.test(draft.phone)) { - return ['phone']; - } -- if (draft.type === 2 && draft.hookAuthType !== 'None' && !hasSecret(draft, 'hookAuthToken')) return ['hookAuthToken']; -- if (draft.type === 10 && ![draft.userId, draft.partyId, draft.tagId].some(value => value.trim())) -- return ['recipientTarget']; -- return []; --} -- export function buildNoticeReceiverPayload(draft: NoticeReceiverDraft) { requireValidNoticeReceiverDraft(draft); const options: Record<string, string | number | string[]> = {}; diff --cc web-app/src/features/monitor/components/monitor-list-results.tsx index 802f2be094,ec2847fc6a..c42aefc3cf --- a/web-app/src/features/monitor/components/monitor-list-results.tsx +++ b/web-app/src/features/monitor/components/monitor-list-results.tsx @@@ -79,17 -78,15 +79,7 @@@ export function MonitorListResults( return <OperationalStatePanel kind="unavailable" title={t('common.unavailable')} />; if (evidence.kind === 'error') return <OperationalStatePanel kind="error" title={t('common.routeError.description')} />; -- const rowSelection: TableRowSelection<MonitorListRow> = { -- selectedRowKeys: selectedIds, - getTitleCheckboxProps: () => - pageSelectionTitleCheckboxProps( - selectedIds, - evidence.records.filter(record => !isMonitorRowDisappeared(record)).map(record => record.id), - pageSelectionLabels(t) - ), - getCheckboxProps: row => ({ disabled: operating || isMonitorRowDisappeared(row) }), - getTitleCheckboxProps: () => ({ 'aria-label': t('monitorActions.selectAll') }), - getCheckboxProps: row => ({ - 'aria-label': t('monitorActions.selectOne', { name: row.name }), - disabled: operating || isMonitorRowDisappeared(row) - }), -- onChange: keys => actions.selectIds(keys.flatMap(key => (typeof key === 'number' ? [key] : []))) -- }; ++ const rowSelection = monitorRowSelection(t, evidence.records, selectedIds, operating, actions.selectIds); const selectionProps = canSelect ? { rowSelection } : {}; return ( <Table<MonitorListRow> @@@ -112,6 -109,6 +102,29 @@@ ); } ++function monitorRowSelection( ++ t: TFunction, ++ records: readonly MonitorListRow[], ++ selectedIds: number[], ++ operating: boolean, ++ selectIds: MonitorResultActions['selectIds'] ++): TableRowSelection<MonitorListRow> { ++ return { ++ selectedRowKeys: selectedIds, ++ getTitleCheckboxProps: () => ++ pageSelectionTitleCheckboxProps( ++ selectedIds, ++ records.filter(record => !isMonitorRowDisappeared(record)).map(record => record.id), ++ pageSelectionLabels(t) ++ ), ++ getCheckboxProps: row => ({ ++ 'aria-label': t('monitorActions.selectOne', { name: row.name }), ++ disabled: operating || isMonitorRowDisappeared(row) ++ }), ++ onChange: keys => selectIds(keys.flatMap(key => (typeof key === 'number' ? [key] : []))) ++ }; ++} ++ function monitorTableChange( changeSort: (sort: MonitorQuery['sort'], order: MonitorQuery['order']) => void ): NonNullable<TableProps<MonitorListRow>['onChange']> { diff --cc web-app/src/features/monitor/components/monitor-list-view.test.tsx index 27d79bbe53,64b97f9a3e..67ac83a1bf --- a/web-app/src/features/monitor/components/monitor-list-view.test.tsx +++ b/web-app/src/features/monitor/components/monitor-list-view.test.tsx @@@ -244,6 -244,21 +244,21 @@@ describe('MonitorListView evidence stat expect(actions.selectIds).not.toHaveBeenCalled(); }); + it('labels bulk and row selection controls with their exact scope', () => { + renderView({ + monitors: { + kind: 'ready', + records: [{ id: 7, name: 'checkout-api', app: 'website', instance: 'prod', status: 1 }], + total: 1 + } + }); + - expect(screen.getByRole('checkbox', { name: i18n.t('monitorActions.selectAll') })).toBeInTheDocument(); ++ expect(screen.getByRole('checkbox', { name: i18n.t('common.tableSelection.selectAll') })).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: i18n.t('monitorActions.selectOne', { name: 'checkout-api' }) }) + ).toBeInTheDocument(); + }); + it('preserves endpoint copy, application drilldown, and discovery target semantics', () => { const copyInstance = vi.fn(); const changeApp = vi.fn(); diff --cc web-app/src/features/monitor/controller/use-monitor-investigation.ts index 0000000000,8bb2204ad0..5b7af62925 mode 000000,100644..100644 --- a/web-app/src/features/monitor/controller/use-monitor-investigation.ts +++ b/web-app/src/features/monitor/controller/use-monitor-investigation.ts @@@ -1,0 -1,58 +1,51 @@@ + /* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + + import { skipToken, useQuery } from '@tanstack/react-query'; + import { useNavigate } from 'react-router-dom'; + + import type { SignalKind } from '@/shared/query-context'; + import { loadMonitorInvestigationBinding } from '../api/monitor-api'; + import type { Monitor } from '../model/monitor-contract'; -import { - monitorDetailRefreshInterval, - type MonitorDetailRefreshSeconds, - type MonitorHistoryChart -} from '../model/monitor-detail-model'; ++import type { MonitorHistoryChart } from '../model/monitor-detail-model'; + import { + buildMonitorInvestigationSignalPath, + createMonitorInvestigation, + monitorInvestigationWindow + } from '../model/monitor-investigation-model'; + import { monitorQueryKeys } from './monitor-query-keys'; + -export function useMonitorInvestigation( - monitor: Monitor | undefined, - chart: MonitorHistoryChart | undefined, - refreshSeconds: MonitorDetailRefreshSeconds -) { ++export function useMonitorInvestigation(monitor: Monitor | undefined, chart: MonitorHistoryChart | undefined) { + const navigate = useNavigate(); + const query = useQuery({ + queryKey: monitorQueryKeys.investigation(monitor?.id), - queryFn: monitor ? ({ signal }) => loadMonitorInvestigationBinding(monitor.id, signal) : skipToken, - refetchInterval: monitor ? monitorDetailRefreshInterval(refreshSeconds) : false, ++ queryFn: monitor ++ ? async ({ signal }) => (await loadMonitorInvestigationBinding(monitor.id, signal)) ?? null ++ : skipToken, + retry: false + }); + const binding = query.data?.monitorId === monitor?.id ? query.data : undefined; + return { + signals: binding ? [...binding.signals] : [], + open: (signal: SignalKind) => { + if (!monitor || !binding || !chart || !binding.signals.includes(signal)) return; + try { + const window = monitorInvestigationWindow( + chart.history, + Date.now(), + Intl.DateTimeFormat().resolvedOptions().timeZone + ); + const investigation = createMonitorInvestigation(monitor, binding, window); + const path = buildMonitorInvestigationSignalPath(investigation, signal); + if (path) void navigate(path); + } catch { + // Invalid local time or stale identity must not create a partial handoff. + } + }, - refetch: query.refetch ++ refetch: () => (monitor ? query.refetch() : Promise.resolve()) + }; + } diff --cc web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts index 3ed6765562,bf0cbb4d66..26da2ec1db --- a/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts +++ b/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts @@@ -68,6 -69,7 +69,7 @@@ function useMonitorMetricContext const layout = useMonitorMetricLayoutController(monitorLayoutApplication(monitor), realtimeSelection.names); const historyCharts = buildHistoryChartEvidence(historySelection, queries); const selectedHistoryChart = buildSelectedHistoryChart(metric, historySelection, queries); - const investigation = useMonitorInvestigation(monitor, selectedHistoryChart, options.refreshControl.refreshSeconds); ++ const investigation = useMonitorInvestigation(monitor, selectedHistoryChart); const actions = buildWorkbenchActions({ monitor, metric, diff --cc web-app/src/features/settings/system-config/api/public-access-config-api.ts index 22e51bc8a5,0000000000..770c6d7f7f mode 100644,000000..100644 --- a/web-app/src/features/settings/system-config/api/public-access-config-api.ts +++ b/web-app/src/features/settings/system-config/api/public-access-config-api.ts @@@ -1,23 -1,0 +1,23 @@@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { apiMessageGet, apiMessagePost } from '@/core/http/api-message'; + +import type { PublicAccessConfig } from '../model/public-access-config-model'; +import { parsePublicAccessConfig } from './public-access-config-schema'; + - export const publicAccessConfigEndpoint = '/api/config/public-access'; ++const publicAccessConfigEndpoint = '/api/config/public-access'; +export const publicAccessConfigQueryKey = ['settings', 'public-access'] as const; + +export async function loadPublicAccessConfig(signal?: AbortSignal) { + const value = signal + ? await apiMessageGet(publicAccessConfigEndpoint, { signal }) + : await apiMessageGet(publicAccessConfigEndpoint); + return parsePublicAccessConfig(value); +} + +export async function savePublicAccessConfig(config: PublicAccessConfig, signal?: AbortSignal) { + const value = signal + ? await apiMessagePost(publicAccessConfigEndpoint, config, { signal }) + : await apiMessagePost(publicAccessConfigEndpoint, config); + return parsePublicAccessConfig(value); +} diff --cc web-app/src/features/settings/system-config/components/public-access-config-section.test.tsx index 4110f1a046,0000000000..032c897573 mode 100644,000000..100644 --- a/web-app/src/features/settings/system-config/components/public-access-config-section.test.tsx +++ b/web-app/src/features/settings/system-config/components/public-access-config-section.test.tsx @@@ -1,67 -1,0 +1,68 @@@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const controller = vi.hoisted(() => ({ + actions: { discard: vi.fn(), retry: vi.fn(), save: vi.fn(), update: vi.fn() }, + state: { + kind: 'ready' as const, + current: { + publicBaseUrl: 'https://hertzbeat.example.test/ops', + serverOtlpHttpEndpoint: '', + serverOtlpGrpcEndpoint: '' + }, + dirty: false, + saving: false, + valid: true + } +})); + - vi.mock('../controller/use-public-access-config-controller', () => ({ - usePublicAccessConfigController: () => controller - })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +import { PublicAccessConfigSection } from './public-access-config-section'; + +describe('PublicAccessConfigSection', () => { + afterEach(() => { + cleanup(); + controller.state.current.serverOtlpHttpEndpoint = ''; + controller.state.current.serverOtlpGrpcEndpoint = ''; + vi.clearAllMocks(); + }); + + it('keeps OTLP overrides collapsed until the operator opens advanced settings', () => { - render(<PublicAccessConfigSection canConfigure />); ++ renderSection(); + + const summary = screen.getByText('systemConfig.publicAccess.advancedTitle').closest('summary'); + const details = summary?.closest('details'); + expect(details).not.toHaveAttribute('open'); + + fireEvent.click(summary!); + + expect(details).toHaveAttribute('open'); + expect(screen.getByText('systemConfig.publicAccess.otlpHttp')).toBeInTheDocument(); + expect(screen.getByText('systemConfig.publicAccess.otlpGrpc')).toBeInTheDocument(); + }); + + it('shows derived defaults as placeholders without turning them into persisted overrides', () => { - render(<PublicAccessConfigSection canConfigure />); ++ renderSection(); + + expect(screen.getByPlaceholderText('https://hertzbeat.example.test:4318')).toHaveValue(''); + expect(screen.getByPlaceholderText('https://hertzbeat.example.test:4317')).toHaveValue(''); + expect(controller.actions.update).not.toHaveBeenCalled(); + }); + + it('opens advanced settings when an OTLP override is already configured', () => { + controller.state.current.serverOtlpHttpEndpoint = 'https://collector.example.test/v1/metrics'; + - render(<PublicAccessConfigSection canConfigure />); ++ renderSection(); + + expect(screen.getByText('systemConfig.publicAccess.advancedTitle').closest('details')).toHaveAttribute('open'); + }); +}); ++ ++function renderSection() { ++ return render(<PublicAccessConfigSection canConfigure state={controller.state} actions={controller.actions} />); ++} diff --cc web-app/src/features/settings/system-config/components/public-access-config-section.tsx index a08365ff13,0000000000..19e3e82d0f mode 100644,000000..100644 --- a/web-app/src/features/settings/system-config/components/public-access-config-section.tsx +++ b/web-app/src/features/settings/system-config/components/public-access-config-section.tsx @@@ -1,158 -1,0 +1,193 @@@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { Button, Input, Typography } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { OperationalFormActions, OperationalStatePanel } from '@/shared/operational-page'; + - import { usePublicAccessConfigController } from '../controller/use-public-access-config-controller'; +import { derivePublicAccessEndpoints, type PublicAccessConfigDraft } from '../model/public-access-config-model'; +import styles from './system-config-editor.module.css'; + - export function PublicAccessConfigSection({ canConfigure }: { canConfigure: boolean }) { ++type PublicAccessConfigSectionState = ++ | { kind: 'loading' } ++ | { kind: 'unavailable' | 'invalid' } ++ | { ++ kind: 'ready'; ++ current: PublicAccessConfigDraft; ++ dirty: boolean; ++ saving: boolean; ++ valid: boolean; ++ }; ++ ++type PublicAccessConfigSectionActions = { ++ discard: () => unknown; ++ retry: () => unknown; ++ save: () => unknown; ++ update: (field: keyof PublicAccessConfigDraft, value: string) => void; ++}; ++ ++export function PublicAccessConfigSection({ ++ canConfigure, ++ state, ++ actions ++}: { ++ canConfigure: boolean; ++ state: PublicAccessConfigSectionState; ++ actions: PublicAccessConfigSectionActions; ++}) { + const { t } = useTranslation(); - const controller = usePublicAccessConfigController(canConfigure); - const state = controller.state; + + return ( + <section className={styles.section} aria-labelledby="public-access-config-title"> + <header className={styles.sectionHeading}> + <Typography.Title id="public-access-config-title" level={3}> + {t('systemConfig.publicAccess.title')} + </Typography.Title> + <Typography.Paragraph type="secondary">{t('systemConfig.publicAccess.description')}</Typography.Paragraph> + </header> + {state.kind === 'loading' && ( + <OperationalStatePanel kind="loading" title={t('systemConfig.publicAccess.loading')} presentation="quiet" /> + )} + {(state.kind === 'unavailable' || state.kind === 'invalid') && ( + <OperationalStatePanel + kind={state.kind === 'invalid' ? 'error' : 'unavailable'} + title={t(`systemConfig.publicAccess.${state.kind}`)} + action={ - <Button size="small" onClick={controller.actions.retry}> ++ <Button size="small" onClick={actions.retry}> + {t('common.retry')} + </Button> + } + presentation="quiet" + /> + )} + {state.kind === 'ready' && ( - <> - <div className={styles.form}> - <PublicAddressField - field="publicBaseUrl" - label={t('systemConfig.publicAccess.publicBaseUrl')} - help={t('systemConfig.publicAccess.publicBaseUrlHelp')} - value={state.current.publicBaseUrl} - disabled={!canConfigure || state.saving} - update={controller.actions.update} - /> - <OtlpOverrideFields - draft={state.current} - disabled={!canConfigure || state.saving} - update={controller.actions.update} - /> - </div> - {canConfigure && state.dirty && ( - <OperationalFormActions> - <Button type="primary" loading={state.saving} disabled={!state.valid} onClick={controller.actions.save}> - {t('common.save')} - </Button> - <Button disabled={state.saving} onClick={controller.actions.discard}> - {t('systemConfig.discard')} - </Button> - </OperationalFormActions> - )} - </> ++ <PublicAccessReadySection canConfigure={canConfigure} state={state} actions={actions} /> + )} + </section> + ); +} + ++function PublicAccessReadySection({ ++ canConfigure, ++ state, ++ actions ++}: { ++ canConfigure: boolean; ++ state: Extract<PublicAccessConfigSectionState, { kind: 'ready' }>; ++ actions: PublicAccessConfigSectionActions; ++}) { ++ const { t } = useTranslation(); ++ const disabled = !canConfigure || state.saving; ++ return ( ++ <> ++ <div className={styles.form}> ++ <PublicAddressField ++ field="publicBaseUrl" ++ label={t('systemConfig.publicAccess.publicBaseUrl')} ++ help={t('systemConfig.publicAccess.publicBaseUrlHelp')} ++ value={state.current.publicBaseUrl} ++ disabled={disabled} ++ update={actions.update} ++ /> ++ <OtlpOverrideFields draft={state.current} disabled={disabled} update={actions.update} /> ++ </div> ++ {canConfigure && state.dirty && ( ++ <OperationalFormActions> ++ <Button type="primary" loading={state.saving} disabled={!state.valid} onClick={actions.save}> ++ {t('common.save')} ++ </Button> ++ <Button disabled={state.saving} onClick={actions.discard}> ++ {t('systemConfig.discard')} ++ </Button> ++ </OperationalFormActions> ++ )} ++ </> ++ ); ++} ++ +function OtlpOverrideFields({ + draft, + disabled, + update +}: { + draft: PublicAccessConfigDraft; + disabled: boolean; + update: (field: keyof PublicAccessConfigDraft, value: string) => void; +}) { + const { t } = useTranslation(); + const defaults = derivePublicAccessEndpoints(draft.publicBaseUrl); + const [advancedOpen, setAdvancedOpen] = useState(() => + Boolean(draft.serverOtlpHttpEndpoint || draft.serverOtlpGrpcEndpoint) + ); + + return ( + <details + className={styles.advanced} + open={advancedOpen} + onToggle={event => setAdvancedOpen(event.currentTarget.open)} + > + <summary className={styles.advancedSummary}> + <RightOutlined className={styles.advancedChevron} aria-hidden /> + <span className={styles.advancedCopy}> + <span className={styles.advancedTitle}>{t('systemConfig.publicAccess.advancedTitle')}</span> + <Typography.Text type="secondary">{t('systemConfig.publicAccess.advancedHelp')}</Typography.Text> + </span> + </summary> + <div className={styles.advancedFields}> + <PublicAddressField + field="serverOtlpHttpEndpoint" + label={t('systemConfig.publicAccess.otlpHttp')} + help={t('systemConfig.publicAccess.otlpHttpHelp')} + value={draft.serverOtlpHttpEndpoint} + {...(defaults?.http ? { placeholder: defaults.http } : {})} + disabled={disabled} + update={update} + /> + <PublicAddressField + field="serverOtlpGrpcEndpoint" + label={t('systemConfig.publicAccess.otlpGrpc')} + help={t('systemConfig.publicAccess.otlpGrpcHelp')} + value={draft.serverOtlpGrpcEndpoint} + {...(defaults?.grpc ? { placeholder: defaults.grpc } : {})} + disabled={disabled} + update={update} + /> + </div> + </details> + ); +} + +function PublicAddressField({ + field, + label, + help, + value, + placeholder, + disabled, + update +}: { + field: keyof PublicAccessConfigDraft; + label: string; + help: string; + value: string; + placeholder?: string; + disabled: boolean; + update: (field: keyof PublicAccessConfigDraft, value: string) => void; +}) { + return ( + <label className={styles.field}> + <span className={styles.label}>{label}</span> + <span className={styles.control}> + <Input + value={value} + placeholder={placeholder} + disabled={disabled} + onChange={event => update(field, event.target.value)} + /> + <Typography.Text type="secondary">{help}</Typography.Text> + </span> + </label> + ); +} diff --cc web-app/src/features/settings/system-config/pages/system-config-page.test.tsx index eddf75b4b6,1df176b947..0bd1b122ad --- a/web-app/src/features/settings/system-config/pages/system-config-page.test.tsx +++ b/web-app/src/features/settings/system-config/pages/system-config-page.test.tsx @@@ -34,12 -34,9 +34,20 @@@ const controller = vi.hoisted(() => ( useCurrentServerSettings: vi.fn(), useSystemConfigResourceController: vi.fn() })); ++const publicAccessController = vi.hoisted(() => ({ ++ actions: { discard: vi.fn(), retry: vi.fn(), save: vi.fn(), update: vi.fn() }, ++ state: { kind: 'loading' as const }, ++ usePublicAccessConfigController: vi.fn() ++})); vi.mock('../controller/system-config-resource-controller', () => ({ useSystemConfigResourceController: controller.useSystemConfigResourceController })); ++vi.mock('../controller/use-public-access-config-controller', () => ({ ++ usePublicAccessConfigController: publicAccessController.usePublicAccessConfigController ++})); +vi.mock('../components/public-access-config-section', () => ({ + PublicAccessConfigSection: () => <section aria-label="Public access settings" /> +})); import { SystemConfigPage } from './system-config-page'; @@@ -53,6 -50,6 +61,7 @@@ describe('SystemConfigPage', () => beforeEach(() => { vi.clearAllMocks(); controller.useSystemConfigResourceController.mockReturnValue(buildController()); ++ publicAccessController.usePublicAccessConfigController.mockReturnValue(publicAccessController); }); afterEach(cleanup); diff --cc web-app/src/features/settings/system-config/pages/system-config-page.tsx index 562c0f3286,28b328f248..771d15394c --- a/web-app/src/features/settings/system-config/pages/system-config-page.tsx +++ b/web-app/src/features/settings/system-config/pages/system-config-page.tsx @@@ -27,9 -27,8 +27,10 @@@ import } from '@/shared/operational-page'; import { SystemConfigEditor } from '../components/system-config-editor'; +import { PublicAccessConfigSection } from '../components/public-access-config-section'; import { RetryButton, SystemConfigReadFailure } from '../components/system-config-state'; import { useSystemConfigResourceController } from '../controller/system-config-resource-controller'; ++import { usePublicAccessConfigController } from '../controller/use-public-access-config-controller'; export function SystemConfigPage() { const { t } = useTranslation(); @@@ -58,8 -57,8 +59,15 @@@ function SystemConfigContent({ controll } function SystemConfigReadyContent({ controller }: { controller: SystemConfigController }) { ++ const { state } = controller; ++ if (state.kind !== 'ready') return null; ++ return <SystemConfigReadyBody controller={controller} />; ++} ++ ++function SystemConfigReadyBody({ controller }: { controller: SystemConfigController }) { const { t } = useTranslation(); const { state } = controller; ++ const publicAccessController = usePublicAccessConfigController(state.kind === 'ready' && state.canConfigure); if (state.kind !== 'ready') return null; return ( <> @@@ -94,7 -93,6 +102,11 @@@ onSave={controller.save} onDiscard={controller.discard} /> - <PublicAccessConfigSection canConfigure={state.canConfigure} /> ++ <PublicAccessConfigSection ++ canConfigure={state.canConfigure} ++ state={publicAccessController.state} ++ actions={publicAccessController.actions} ++ /> </> ); } diff --cc web-app/src/features/setup/model/setup-optional.ts index bc9254b26f,0723b00c6c..632e24f830 --- a/web-app/src/features/setup/model/setup-optional.ts +++ b/web-app/src/features/setup/model/setup-optional.ts @@@ -110,47 -113,12 +110,47 @@@ export function clearOptionalMailSecret return { ...draft, mail: { ...draft.mail, password: '' } }; } -function normalizedPublicAccess(draft: SetupOptionalDraft): SetupPublicAccess { - return compactStrings({ - publicBaseUrl: draft.publicBaseUrl, - serverOtlpHttpEndpoint: draft.serverOtlpHttpEndpoint, - serverOtlpGrpcEndpoint: draft.serverOtlpGrpcEndpoint - }); +function normalizedPublicAccess(draft: SetupOptionalDraft, publicOrigin: string): SetupPublicAccess { + const candidate = draft.useProxy ? draft.proxyPublicBaseUrl : publicOrigin; + const publicBaseUrl = normalizeSetupPublicBaseUrl(candidate); + if (!publicBaseUrl) throw new Error('Invalid setup public base URL'); + return { publicBaseUrl }; +} + - export function setupPublicAccessValid(draft: SetupOptionalDraft) { ++function setupPublicAccessValid(draft: SetupOptionalDraft) { + return !draft.useProxy || normalizeSetupPublicBaseUrl(draft.proxyPublicBaseUrl) !== null; +} + +export function setupPublicAccessValidationReady(draft: SetupOptionalDraft) { + return setupPublicAccessValid(draft); +} + +function normalizeSetupPublicBaseUrl(value: string) { + const candidate = value.trim(); + if (!candidate) return null; + try { + const address = new URL(candidate); + if ( + (address.protocol !== 'http:' && address.protocol !== 'https:') || + !address.hostname || + address.username || + address.password || + address.search || + address.hash || + address.port === '0' || + wildcardHost(address.hostname) + ) { + return null; + } + return candidate; + } catch { + return null; + } +} + +function wildcardHost(value: string) { + const host = value.toLowerCase().replace(/^\[|\]$/g, ''); + return host === '0.0.0.0' || host === '::' || host === '0:0:0:0:0:0:0:0'; } function normalizedMail(mail: SetupOptionalDraft['mail']): SetupMail { diff --cc web-app/src/features/status/management/components/status-org-settings-workspace.tsx index 16fa6f38ae,0000000000..f2e9a8cccb mode 100644,000000..100644 --- a/web-app/src/features/status/management/components/status-org-settings-workspace.tsx +++ b/web-app/src/features/status/management/components/status-org-settings-workspace.tsx @@@ -1,105 -1,0 +1,105 @@@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Form } from 'antd'; +import type { FormInstance } from 'antd'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { StatusOrg } from '../model/status-management-contract'; +import styles from './status-org-settings.module.css'; +import { StatusOrgSummary } from './status-org-presentation'; +import { StatusWriteRecoveryAlert } from './status-write-recovery-alert'; + +interface ConfiguredWorkspaceProps { + org: StatusOrg; + editing: boolean; + actions: ReactNode; + fields: ReactNode; + writeRecovery: 'proof' | 'commit-uncertain' | undefined; +} + +interface FormWorkspaceProps extends Omit<ConfiguredWorkspaceProps, 'org'> { + org: StatusOrg | undefined; + form: FormInstance<StatusOrg>; + onFinish: (value: StatusOrg) => void; +} + +export function StatusOrgFormWorkspace(props: FormWorkspaceProps) { + const configured = props.org ? <StatusOrgConfiguredWorkspace {...props} org={props.org} /> : undefined; + if (configured && !props.editing) return configured; + return ( + <Form form={props.form} layout="vertical" onFinish={props.onFinish}> + {configured ?? ( + <StatusOrgSetupWorkspace actions={props.actions} fields={props.fields} writeRecovery={props.writeRecovery} /> + )} + </Form> + ); +} + +function StatusOrgConfiguredWorkspace(props: ConfiguredWorkspaceProps) { + const content = props.editing ? ( + <> + {props.writeRecovery && <StatusWriteRecoveryAlert />} + <div className={styles.settingsBody}>{props.fields}</div> + </> + ) : ( + <div className={styles.settingsBody}> + <StatusOrgSummary org={props.org} /> + </div> + ); + return ( + <div className={styles.configurationWorkspace}> + <StatusOrgSettingsPanel actions={props.actions}>{content}</StatusOrgSettingsPanel> + </div> + ); +} + - export function StatusOrgSetupWorkspace({ ++function StatusOrgSetupWorkspace({ + actions, + fields, + writeRecovery +}: { + actions: ReactNode; + fields: ReactNode; + writeRecovery: 'proof' | 'commit-uncertain' | undefined; +}) { + return ( + <div className={styles.setupWorkspace}> + <div className={styles.setupForm}> + {writeRecovery && <StatusWriteRecoveryAlert />} + {fields} + {actions} + </div> + </div> + ); +} + +function StatusOrgSettingsPanel({ actions, children }: { actions: ReactNode; children: ReactNode }) { + const { t } = useTranslation(); + return ( + <section className={styles.settingsPanel} aria-label={t('statusManagement.organization')}> + <div className={styles.settingsContent}> + <header className={styles.settingsContentHeader}> + <h3>{t('statusManagement.organization')}</h3> + {actions} + </header> + {children} + </div> + </section> + ); +} diff --cc web-app/src/layout/shell/shell-navigation-style.test.ts index 2fdf4112d0,3d0b650508..a1fbbca664 --- a/web-app/src/layout/shell/shell-navigation-style.test.ts +++ b/web-app/src/layout/shell/shell-navigation-style.test.ts @@@ -23,14 -23,11 +23,22 @@@ describe('shell navigation visual hiera expect(shellStyles).toMatch(/\.navigationParentOpen\s*\{[^}]*background:\s*var\(--hb-nav-hover\)/); }); + it('animates accordion height and chevrons while respecting reduced-motion preferences', () => { + expect(shellStyles).toMatch( + /\.navigationChildrenMotion\s*\{[^}]*grid-template-rows:\s*0fr[^}]*transition:[^}]*grid-template-rows/s + ); + expect(shellStyles).toMatch(/\.navigationChildrenMotion\[data-open='true'\]\s*\{[^}]*grid-template-rows:\s*1fr/s); + expect(shellStyles).toMatch(/\.navigationChevron[^}]*transition:[^}]*transform/s); + expect(shellStyles).toMatch( + /@media\s*\(prefers-reduced-motion:\s*reduce\)[\s\S]*\.navigationChildrenMotion[\s\S]*transition-duration:\s*0\.01ms/ + ); + }); ++ + it('marks the active destination with a line instead of a filled selection block', () => { + const activeRule = shellStyles.match(/\.navigationLinkActive\s*\{[^}]*\}/)?.[0] ?? ''; + + expect(activeRule).toMatch(/border-left-color:\s*var\(--hb-brand-accent\)/); + expect(activeRule).toMatch(/background:\s*transparent/); + expect(activeRule).not.toMatch(/background:\s*var\(--hb-nav-selected\)/); + }); }); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
