[8.17] [Logs UI] Allow editing of non-resolving log views (#210633) (#211242)

# Backport

This will backport the following commits from `8.x` to `8.17`:
- [[Logs UI] Allow editing of non-resolving log views
(#210633)](https://github.com/elastic/kibana/pull/210633)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Felix
Stürmer","email":"weltenwort@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-02-14T14:55:17Z","message":"[Logs
UI] Allow editing of non-resolving log views (#210633)\n\nThis changes
the settings page of the Logs UI such that it allows editing of log view
settings even if the resolution or status check failed. This allows
recovery from various situations that were previously only recoverable
by resetting the log view
completely.","sha":"41cd657811bd1c00ac15860268c018d4d80085ac","branchLabelMapping":{"^v8.16.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:fix","Feature:Logs
UI","backport:skip","Team:obs-ux-logs","v8.18.0","v8.17.3"],"title":"[Logs
UI] Allow editing of non-resolving log
views","number":210633,"url":"https://github.com/elastic/kibana/pull/210633","mergeCommit":{"message":"[Logs
UI] Allow editing of non-resolving log views (#210633)\n\nThis changes
the settings page of the Logs UI such that it allows editing of log view
settings even if the resolution or status check failed. This allows
recovery from various situations that were previously only recoverable
by resetting the log view
completely.","sha":"41cd657811bd1c00ac15860268c018d4d80085ac"}},"sourceBranch":"8.x","suggestedTargetBranches":["8.18","8.17"],"targetPullRequestStates":[{"branch":"8.18","label":"v8.18.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.17","label":"v8.17.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Felix Stürmer 2025-02-15 07:18:18 +01:00 committed by GitHub
parent b7cd76ef91
commit 4de5655725
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 211 additions and 55 deletions

View file

@ -59,7 +59,7 @@ type IndicesConfigurationPanelProps = PropsOf<typeof IndicesConfigurationPanel>;
type IndicesConfigurationPanelStoryArgs = Pick<
IndicesConfigurationPanelProps,
'isLoading' | 'isReadOnly'
'isLoading' | 'isReadOnly' | 'logViewStatus'
> & {
availableIndexPatterns: MockIndexPatternSpec[];
logIndices: LogIndicesFormState;
@ -69,6 +69,7 @@ const IndicesConfigurationPanelTemplate: Story<IndicesConfigurationPanelStoryArg
isLoading,
isReadOnly,
logIndices,
logViewStatus,
}) => {
const logIndicesFormElement = useLogIndicesFormElement(logIndices);
@ -78,6 +79,7 @@ const IndicesConfigurationPanelTemplate: Story<IndicesConfigurationPanelStoryArg
isLoading={isLoading}
isReadOnly={isReadOnly}
indicesFormElement={logIndicesFormElement}
logViewStatus={logViewStatus}
/>
<EuiCodeBlock language="json">
// field states{'\n'}
@ -103,6 +105,10 @@ const defaultArgs: IndicesConfigurationPanelStoryArgs = {
type: 'index_name' as const,
indexName: 'logs-*',
},
logViewStatus: {
index: 'missing',
reason: 'remoteClusterNotFound',
},
availableIndexPatterns: [
{
id: 'INDEX_PATTERN_A',

View file

@ -9,11 +9,14 @@ import { EuiCheckableCard, EuiFormFieldset, EuiSpacer, EuiTitle } from '@elastic
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useState } from 'react';
import { useUiTracker } from '@kbn/observability-shared-plugin/public';
import type {
LogDataViewReference,
LogIndexReference,
LogViewStatus,
} from '@kbn/logs-shared-plugin/common';
import {
logIndexNameReferenceRT,
LogDataViewReference,
logDataViewReferenceRT,
LogIndexReference,
logSourcesKibanaAdvancedSettingRT,
} from '@kbn/logs-shared-plugin/common';
import { EuiCallOut } from '@elastic/eui';
@ -34,7 +37,8 @@ export const IndicesConfigurationPanel = React.memo<{
isLoading: boolean;
isReadOnly: boolean;
indicesFormElement: FormElement<LogIndexReference | undefined, FormValidationError>;
}>(({ isLoading, isReadOnly, indicesFormElement }) => {
logViewStatus: LogViewStatus;
}>(({ isLoading, isReadOnly, indicesFormElement, logViewStatus }) => {
const {
services: {
http,
@ -198,6 +202,7 @@ export const IndicesConfigurationPanel = React.memo<{
/>
)}
</EuiCheckableCard>
<LogViewStatusWarning logViewStatus={logViewStatus} />
{numberOfLogsRules > 0 && indicesFormElement.isDirty && (
<>
<EuiSpacer size="s" />
@ -232,6 +237,36 @@ export const IndicesConfigurationPanel = React.memo<{
);
});
const LogViewStatusWarning: React.FC<{ logViewStatus: LogViewStatus }> = ({ logViewStatus }) => {
if (logViewStatus.index === 'missing') {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut title={logIndicesMissingTitle} color="warning" iconType="warning">
{logViewStatus.reason === 'noShardsFound' ? (
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesMissingMessage.noShardsFound"
defaultMessage="No shards found for the specified indices."
/>
) : logViewStatus.reason === 'noIndicesFound' ? (
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesMissingMessage.noIndicesFound"
defaultMessage="No indices found for the specified pattern."
/>
) : logViewStatus.reason === 'remoteClusterNotFound' ? (
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesMissingMessage.remoteClusterNotFound"
defaultMessage="At least one remote cluster was not found."
/>
) : null}
</EuiCallOut>
</>
);
} else {
return null;
}
};
const isDataViewFormElement = isFormElementForType(
(value): value is LogDataViewReference | undefined =>
value == null || logDataViewReferenceRT.is(value)
@ -242,3 +277,10 @@ const isIndexNamesFormElement = isFormElementForType(logIndexNameReferenceRT.is)
const isKibanaAdvancedSettingFormElement = isFormElementForType(
logSourcesKibanaAdvancedSettingRT.is
);
const logIndicesMissingTitle = i18n.translate(
'xpack.infra.sourceConfiguration.logIndicesMissingTitle',
{
defaultMessage: 'Log indices missing',
}
);

View file

@ -20,6 +20,8 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Prompt } from '@kbn/observability-shared-plugin/public';
import { useTrackPageview } from '@kbn/observability-shared-plugin/public';
import { useLogViewContext } from '@kbn/logs-shared-plugin/public';
import type { LogView, LogViewAttributes, LogViewStatus } from '@kbn/logs-shared-plugin/common';
import { SourceErrorPage } from '../../../components/source_error_page';
import { LogsDeprecationCallout } from '../../../components/logs_deprecation_callout';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs';
@ -50,14 +52,17 @@ export const LogsSettingsPage = () => {
]);
const {
logView,
hasFailedLoadingLogView,
isInlineLogView,
isLoading,
isUninitialized,
update,
latestLoadLogViewFailures,
logView,
logViewStatus,
resolvedLogView,
isInlineLogView,
retry,
revertToDefaultLogView,
update,
} = useLogViewContext();
const availableFields = useMemo(
@ -65,6 +70,47 @@ export const LogsSettingsPage = () => {
[resolvedLogView]
);
const isWriteable = shouldAllowEdit && logView != null && logView.origin !== 'internal';
if ((isLoading || isUninitialized) && logView == null) {
return <SourceLoadingPage />;
} else if (hasFailedLoadingLogView || logView == null) {
return <SourceErrorPage errorMessage={latestLoadLogViewFailures[0].message} retry={retry} />;
} else {
return (
<LogsSettingsPageContent
availableFields={availableFields}
isInlineLogView={isInlineLogView}
isLoading={isLoading}
isWriteable={isWriteable}
logView={logView}
logViewStatus={logViewStatus}
revertToDefaultLogView={revertToDefaultLogView}
onUpdateLogViewAttributes={update}
/>
);
}
};
const LogsSettingsPageContent = ({
availableFields,
isInlineLogView,
isLoading,
isWriteable,
logView,
logViewStatus,
revertToDefaultLogView,
onUpdateLogViewAttributes,
}: {
availableFields: string[];
isInlineLogView: boolean;
isLoading: boolean;
isWriteable: boolean;
logView: LogView;
logViewStatus: LogViewStatus;
revertToDefaultLogView: () => void;
onUpdateLogViewAttributes: (logViewAttributes: Partial<LogViewAttributes>) => Promise<void>;
}) => {
const {
sourceConfigurationFormElement,
formState,
@ -74,21 +120,15 @@ export const LogsSettingsPage = () => {
} = useLogSourceConfigurationFormState(logView?.attributes);
const persistUpdates = useCallback(async () => {
await update(formState);
sourceConfigurationFormElement.resetValue();
}, [update, sourceConfigurationFormElement, formState]);
const isWriteable = useMemo(
() => shouldAllowEdit && logView && logView.origin !== 'internal',
[shouldAllowEdit, logView]
);
if ((isLoading || isUninitialized) && !resolvedLogView) {
return <SourceLoadingPage />;
}
if (hasFailedLoadingLogView) {
return null;
}
try {
await onUpdateLogViewAttributes(formState);
sourceConfigurationFormElement.resetValue();
} catch {
// the error is handled in the state machine already, but without this the
// global promise rejection tracker would complain about it being
// unhandled
}
}, [onUpdateLogViewAttributes, sourceConfigurationFormElement, formState]);
return (
<EuiErrorBoundary>
@ -124,6 +164,7 @@ export const LogsSettingsPage = () => {
isLoading={isLoading}
isReadOnly={!isWriteable}
indicesFormElement={logIndicesFormElement}
logViewStatus={logViewStatus}
/>
</EuiPanel>
<EuiSpacer />

View file

@ -107,7 +107,7 @@ describe('Logs UI Observability Homepage Functions', () => {
setup();
getResolvedLogView.mockResolvedValue(createResolvedLogViewMock({ indices: 'test-index' }));
getResolvedLogViewStatus.mockResolvedValue({ index: 'missing' });
getResolvedLogViewStatus.mockResolvedValue({ index: 'missing', reason: 'noShardsFound' });
const hasData = getLogsHasDataFetcher(mockedGetStartServices);
const response = await hasData();

View file

@ -41,6 +41,7 @@ export {
FetchLogViewError,
FetchLogViewStatusError,
ResolveLogViewError,
isNoSuchRemoteClusterError,
} from './log_views/errors';
// eslint-disable-next-line @kbn/eslint/no_export_all

View file

@ -38,3 +38,6 @@ export class PutLogViewError extends Error {
this.name = 'PutLogViewError';
}
}
export const isNoSuchRemoteClusterError = (err: Error) =>
err?.message?.includes('no_such_remote_cluster_exception');

View file

@ -60,11 +60,16 @@ const resolveLegacyReference = async (
}
const indices = logViewAttributes.logIndices.indexName;
const dataViewId = `log-view-${logViewId}`;
// If we didn't remove the item from the cache here the subsequent call to
// create would not have any effect
dataViewsService.clearInstanceCache(dataViewId);
const dataViewReference = await dataViewsService
.create(
{
id: `log-view-${logViewId}`,
id: dataViewId,
name: logViewAttributes.name,
title: indices,
timeFieldName: TIMESTAMP_FIELD,
@ -134,11 +139,16 @@ const resolveKibanaAdvancedSettingReference = async (
const indices = (await logSourcesService.getLogSources())
.map((logSource) => logSource.indexPattern)
.join(',');
const dataViewId = `log-view-${logViewId}`;
// If we didn't remove the item from the cache here the subsequent call to
// create would not have any effect
dataViewsService.clearInstanceCache(dataViewId);
const dataViewReference = await dataViewsService
.create(
{
id: `log-view-${logViewId}`,
id: dataViewId,
name: logViewAttributes.name,
title: indices,
timeFieldName: TIMESTAMP_FIELD,

View file

@ -106,17 +106,25 @@ export const logViewRT = rt.exact(
);
export type LogView = rt.TypeOf<typeof logViewRT>;
export const logViewIndexStatusRT = rt.keyof({
available: null,
empty: null,
missing: null,
unknown: null,
});
export type LogViewIndexStatus = rt.TypeOf<typeof logViewIndexStatusRT>;
export const logViewStatusRT = rt.strict({
index: logViewIndexStatusRT,
});
export const logViewStatusRT = rt.union([
rt.strict({
index: rt.literal('available'),
}),
rt.strict({
index: rt.literal('empty'),
}),
rt.strict({
index: rt.literal('missing'),
reason: rt.keyof({
noIndicesFound: null,
noShardsFound: null,
remoteClusterNotFound: null,
}),
}),
rt.strict({
index: rt.literal('unknown'),
}),
]);
export type LogViewStatus = rt.TypeOf<typeof logViewStatusRT>;
export const persistedLogViewReferenceRT = rt.type({

View file

@ -9,7 +9,12 @@ import { useInterpret, useSelector } from '@xstate/react';
import createContainer from 'constate';
import { useCallback, useState } from 'react';
import { waitFor } from 'xstate/lib/waitFor';
import { DEFAULT_LOG_VIEW, LogViewAttributes, LogViewReference } from '../../common/log_views';
import {
DEFAULT_LOG_VIEW,
LogViewAttributes,
LogViewReference,
LogViewStatus,
} from '../../common/log_views';
import {
InitializeFromUrl,
UpdateContextInUrl,
@ -73,7 +78,10 @@ export const useLogView = ({
const logView = useSelector(logViewStateService, (state) =>
state.matches('resolving') ||
state.matches('updating') ||
state.matches('checkingStatus') ||
state.matches('resolutionFailed') ||
state.matches('checkingStatusFailed') ||
state.matches('resolvedPersistedLogView') ||
state.matches('resolvedInlineLogView')
? state.context.logView
@ -82,16 +90,19 @@ export const useLogView = ({
const resolvedLogView = useSelector(logViewStateService, (state) =>
state.matches('checkingStatus') ||
state.matches('checkingStatusFailed') ||
state.matches('resolvedPersistedLogView') ||
state.matches('resolvedInlineLogView')
? state.context.resolvedLogView
: undefined
);
const logViewStatus = useSelector(logViewStateService, (state) =>
state.matches('resolvedPersistedLogView') || state.matches('resolvedInlineLogView')
const logViewStatus: LogViewStatus = useSelector(logViewStateService, (state) =>
state.matches('resolvedPersistedLogView') ||
state.matches('resolvedInlineLogView') ||
state.matches('resolutionFailed')
? state.context.status
: undefined
: { index: 'unknown' }
);
const isLoadingLogView = useSelector(logViewStateService, (state) => state.matches('loading'));

View file

@ -7,6 +7,7 @@
import { catchError, from, map, of, throwError } from 'rxjs';
import { createMachine, actions, assign } from 'xstate';
import { isNoSuchRemoteClusterError } from '../../../../common';
import { ILogViewsClient } from '../../../services/log_views';
import { NotificationChannel } from '../../xstate_helpers';
import { LogViewNotificationEvent, logViewNotificationEventSelectors } from './notifications';
@ -75,7 +76,7 @@ export const createPureLogViewStateMachine = (initialContext: LogViewContextWith
on: {
RESOLUTION_FAILED: {
target: 'resolutionFailed',
actions: 'storeError',
actions: ['storeError', 'storeStatusAfterError'],
},
RESOLUTION_SUCCEEDED: {
target: 'checkingStatus',
@ -250,6 +251,20 @@ export const createPureLogViewStateMachine = (initialContext: LogViewContextWith
} as LogViewContextWithStatus)
: {}
),
storeStatusAfterError: assign((context, event) =>
'error' in event
? ({
status: isNoSuchRemoteClusterError(event.error)
? {
index: 'missing',
reason: 'remoteClusterNotFound',
}
: {
index: 'unknown',
},
} as LogViewContextWithStatus)
: {}
),
storeError: assign((context, event) =>
'error' in event
? ({

View file

@ -71,7 +71,7 @@ export type LogViewTypestate =
}
| {
value: 'updating';
context: LogViewContextWithReference;
context: LogViewContextWithReference & LogViewContextWithLogView;
}
| {
value: 'loadingFailed';
@ -83,11 +83,17 @@ export type LogViewTypestate =
}
| {
value: 'resolutionFailed';
context: LogViewContextWithReference & LogViewContextWithLogView & LogViewContextWithError;
context: LogViewContextWithReference &
LogViewContextWithLogView &
LogViewContextWithStatus &
LogViewContextWithError;
}
| {
value: 'checkingStatusFailed';
context: LogViewContextWithReference & LogViewContextWithLogView & LogViewContextWithError;
context: LogViewContextWithReference &
LogViewContextWithLogView &
LogViewContextWithResolvedLogView &
LogViewContextWithError;
};
export type LogViewContext = LogViewTypestate['context'];

View file

@ -25,6 +25,7 @@ import {
PutLogViewError,
ResolvedLogView,
resolveLogView,
isNoSuchRemoteClusterError,
} from '../../../common/log_views';
import { decodeOrThrow } from '../../../common/runtime_types';
import { ILogViewsClient } from './types';
@ -69,7 +70,7 @@ export class LogViewsClient implements ILogViewsClient {
}
public async getResolvedLogViewStatus(resolvedLogView: ResolvedLogView): Promise<LogViewStatus> {
const indexStatus = await lastValueFrom(
return await lastValueFrom(
this.search({
params: {
ignore_unavailable: true,
@ -81,31 +82,43 @@ export class LogViewsClient implements ILogViewsClient {
},
})
).then(
({ rawResponse }) => {
({ rawResponse }): LogViewStatus => {
if (rawResponse._shards.total <= 0) {
return 'missing' as const;
return {
index: 'missing',
reason: 'noShardsFound',
};
}
const totalHits = decodeTotalHits(rawResponse.hits.total);
if (typeof totalHits === 'number' ? totalHits > 0 : totalHits.value > 0) {
return 'available' as const;
return {
index: 'available',
};
}
return 'empty' as const;
return {
index: 'empty',
};
},
(err) => {
(err): LogViewStatus => {
if (err.status === 404) {
return 'missing' as const;
return {
index: 'missing',
reason: 'noIndicesFound',
};
} else if (err != null && isNoSuchRemoteClusterError(err)) {
return {
index: 'missing',
reason: 'remoteClusterNotFound',
};
}
throw new FetchLogViewStatusError(
`Failed to check status of log indices of "${resolvedLogView.indices}": ${err}`
);
}
);
return {
index: indexStatus,
};
}
public async putLogView(