+
Filter events using label querying without spaces, ex:
{`{severity="critical", instance=~"cluster-us-.+"}`}
- Invalid use of spaces:
- {`{severity= "critical"}`}
+ Invalid use of spaces:
+ {`{severity= "alerting.critical"}`}
{`{severity ="critical"}`}
- Valid use of spaces:
+ Valid use of spaces:
{`{severity=" critical"}`}
-
+
Filter alerts using label querying without braces, ex:
{`severity="critical", instance=~"cluster-us-.+"`}
diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx
new file mode 100644
index 00000000000..3b42644ed13
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx
@@ -0,0 +1,236 @@
+import { css } from '@emotion/css';
+import { max, min, uniqBy } from 'lodash';
+import { useMemo } from 'react';
+
+import { FieldType, GrafanaTheme2, LoadingState, PanelData, dateTime, makeTimeRange } from '@grafana/data';
+import { Icon, Stack, Text, useStyles2 } from '@grafana/ui';
+import { Trans, t } from 'app/core/internationalization';
+import { CombinedRule } from 'app/types/unified-alerting';
+
+import { useCombinedRule } from '../../../hooks/useCombinedRule';
+import { parse } from '../../../utils/rule-id';
+import { isGrafanaRulerRule } from '../../../utils/rules';
+import { MetaText } from '../../MetaText';
+import { VizWrapper } from '../../rule-editor/VizWrapper';
+import { AnnotationValue } from '../../rule-viewer/tabs/Details';
+import { LogRecord } from '../state-history/common';
+
+import { EventState } from './EventListSceneObject';
+
+interface EventDetailsProps {
+ record: LogRecord;
+ logRecords: LogRecord[];
+}
+export function EventDetails({ record, logRecords }: EventDetailsProps) {
+ // get the rule from the ruleUID
+ const ruleUID = record.line?.ruleUID ?? '';
+ const identifier = useMemo(() => {
+ return parse(ruleUID, true);
+ }, [ruleUID]);
+ const { error, loading, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
+
+ if (error) {
+ return (
+
+ Error loading rule for this event.
+
+ );
+ }
+ if (loading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (!rule) {
+ return (
+
+ Rule not found for this event.
+
+ );
+ }
+
+ const getTransitionsCountByRuleUID = (ruleUID: string) => {
+ return logRecords.filter((record) => record.line.ruleUID === ruleUID).length;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface StateTransitionProps {
+ record: LogRecord;
+}
+function StateTransition({ record }: StateTransitionProps) {
+ return (
+
+
+ State transition
+
+
+
+
+
+
+
+ );
+}
+
+interface AnnotationsProps {
+ rule: CombinedRule;
+}
+const Annotations = ({ rule }: AnnotationsProps) => {
+ const styles = useStyles2(getStyles);
+ const annotations = rule.annotations;
+ if (!annotations) {
+ return null;
+ }
+ return (
+ <>
+
+ Annotations
+
+ {Object.keys(annotations).length === 0 ? (
+
+ No annotations
+
+ ) : (
+
+ {Object.entries(annotations).map(([name, value]) => (
+
+ {name}
+
+
+ ))}
+
+ )}
+ >
+ );
+};
+
+/**
+ *
+ * This component renders the visualization for the rule condition values over the selected time range.
+ * The visualization is a time series graph with the condition values on the y-axis and time on the x-axis.
+ * The values are extracted from the log records already fetched from the history api.
+ * The graph is rendered only if the rule is a Grafana rule.
+ *
+ */
+interface QueryVizualizationProps {
+ ruleUID: string;
+ rule: CombinedRule;
+ logRecords: LogRecord[];
+}
+const QueryVizualization = ({ ruleUID, rule, logRecords }: QueryVizualizationProps) => {
+ if (!isGrafanaRulerRule(rule?.rulerRule)) {
+ return (
+
+ Rule is not a Grafana rule
+
+ );
+ }
+ // get the condition from the rule
+ const condition = rule?.rulerRule.grafana_alert?.condition ?? 'A';
+ // get the panel data for the rule
+ const panelData = getPanelDataForRule(ruleUID, logRecords, condition);
+ // render the visualization
+ return ;
+};
+
+/**
+ * This function returns the time series panel data for the condtion values of the rule, within the selected time range.
+ * The values are extracted from the log records already fetched from the history api.
+ * @param ruleUID
+ * @param logRecords
+ * @param condition
+ * @returns PanelData
+ */
+export function getPanelDataForRule(ruleUID: string, logRecords: LogRecord[], condition: string) {
+ const ruleLogRecords = logRecords
+ .filter((record) => record.line.ruleUID === ruleUID)
+ // sort by timestamp as time series data is expected to be sorted by time
+ .sort((a, b) => a.timestamp - b.timestamp);
+
+ // get unique records by timestamp, as timeseries data should have unique timestamps, and it might be possible to have multiple records with the same timestamp
+ const uniqueRecords = uniqBy(ruleLogRecords, (record) => record.timestamp);
+
+ const timestamps = uniqueRecords.map((record) => record.timestamp);
+ const values = uniqueRecords.map((record) => (record.line.values ? record.line.values[condition] : 0));
+ const minTimestamp = min(timestamps);
+ const maxTimestamp = max(timestamps);
+
+ const PanelDataObj: PanelData = {
+ series: [
+ {
+ name: 'Rule condition history',
+ fields: [
+ { name: 'Time', values: timestamps, config: {}, type: FieldType.time },
+ { name: 'values', values: values, type: FieldType.number, config: {} },
+ ],
+ length: timestamps.length,
+ },
+ ],
+ state: LoadingState.Done,
+ timeRange: makeTimeRange(dateTime(minTimestamp), dateTime(maxTimestamp)),
+ };
+ return PanelDataObj;
+}
+
+interface ValueInTransitionProps {
+ record: LogRecord;
+}
+function ValueInTransition({ record }: ValueInTransitionProps) {
+ const values = record?.line?.values
+ ? JSON.stringify(record.line.values)
+ : t('alerting.central-alert-history.details.no-values', 'No values');
+ return (
+
+
+ Value in transition
+
+
+
+ {values}
+
+
+
+ );
+}
+interface NumberTransitionsProps {
+ transitions: number;
+}
+function NumberTransitions({ transitions }: NumberTransitionsProps) {
+ return (
+
+
+
+ State transitions for selected period
+
+
+
+ {transitions}
+
+
+ );
+}
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ metadataWrapper: css({
+ display: 'grid',
+ gridTemplateColumns: 'auto auto',
+ rowGap: theme.spacing(3),
+ columnGap: theme.spacing(12),
+ }),
+ };
+};
diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx
index 33d39a6eb01..175c7a304fc 100644
--- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx
+++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx
@@ -1,13 +1,13 @@
-import { css } from '@emotion/css';
-import { useMemo, useState } from 'react';
+import { css, cx } from '@emotion/css';
+import { ReactElement, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
-import { DataFrameJSON, GrafanaTheme2, TimeRange } from '@grafana/data';
+import { DataFrameJSON, GrafanaTheme2, IconName, TimeRange } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, TextBoxVariable, VariableValue, sceneGraph } from '@grafana/scenes';
-import { Alert, Icon, LoadingBar, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
+import { Alert, Icon, LoadingBar, Pagination, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
-import { t } from 'app/core/internationalization';
+import { Trans, t } from 'app/core/internationalization';
import {
GrafanaAlertStateWithReason,
isAlertStateWithReason,
@@ -17,6 +17,7 @@ import {
} from 'app/types/unified-alerting-dto';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
+import { usePagination } from '../../../hooks/usePagination';
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { stringifyErrorLike } from '../../../utils/misc';
@@ -26,8 +27,10 @@ import { LogRecord } from '../state-history/common';
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
import { LABELS_FILTER } from './CentralAlertHistoryScene';
+import { EventDetails } from './EventDetails';
export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level.
+const PAGE_SIZE = 100;
/**
*
@@ -35,13 +38,11 @@ export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE leve
* It fetches the events from the history api and displays them in a list.
* The list is filtered by the labels in the filter variable and by the time range variable in the scene graph.
*/
-export const HistoryEventsList = ({
- timeRange,
- valueInfilterTextBox,
-}: {
+interface HistoryEventsListProps {
timeRange?: TimeRange;
valueInfilterTextBox: VariableValue;
-}) => {
+}
+export const HistoryEventsList = ({ timeRange, valueInfilterTextBox }: HistoryEventsListProps) => {
const from = timeRange?.from.unix();
const to = timeRange?.to.unix();
@@ -85,12 +86,23 @@ interface HistoryLogEventsProps {
logRecords: LogRecord[];
}
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
+ const { page, pageItems, numberOfPages, onPageChange } = usePagination(logRecords, 1, PAGE_SIZE);
return (
-
- {logRecords.map((record) => {
- return ;
- })}
-
+
+
+ {pageItems.map((record) => {
+ return (
+
+ );
+ })}
+
+ {/* This paginations improves the performance considerably , making the page load faster */}
+
+
);
}
@@ -102,17 +114,25 @@ function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
if (isFetchError(error) && error.status === 404) {
return ;
}
- const title = t('central-alert-history.error', 'Something went wrong loading the alert state history');
+ const title = t('alerting.central-alert-history.error', 'Something went wrong loading the alert state history');
+ const errorStr = stringifyErrorLike(error);
- return {stringifyErrorLike(error)};
+ return {errorStr};
}
-function EventRow({ record }: { record: LogRecord }) {
+interface EventRowProps {
+ record: LogRecord;
+ logRecords: LogRecord[];
+}
+function EventRow({ record, logRecords }: EventRowProps) {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
return (
-
-
+ {!isCollapsed && (
+
+
+
+ )}
+
);
}
-function AlertRuleName({ labels, ruleUID }: { labels: Record
; ruleUID?: string }) {
+interface AlertRuleNameProps {
+ labels: Record;
+ ruleUID?: string;
+}
+function AlertRuleName({ labels, ruleUID }: AlertRuleNameProps) {
const styles = useStyles2(getStyles);
const alertRuleName = labels['alertname'];
if (!ruleUID) {
- return {alertRuleName};
+ return (
+
+ Unknown
+ {alertRuleName}
+
+ );
}
return (
@@ -170,55 +204,90 @@ function EventTransition({ previous, current }: EventTransitionProps) {
);
}
-function EventState({ state }: { state: GrafanaAlertStateWithReason }) {
- const styles = useStyles2(getStyles);
+interface StateIconProps {
+ iconName: IconName;
+ iconColor: string;
+ tooltipContent: string;
+ labelText: ReactElement;
+ showLabel: boolean;
+}
+const StateIcon = ({ iconName, iconColor, tooltipContent, labelText, showLabel }: StateIconProps) => (
+
+
+
+ {showLabel && (
+
+ {labelText}
+
+ )}
+
+
+);
+interface EventStateProps {
+ state: GrafanaAlertStateWithReason;
+ showLabel?: boolean;
+}
+export function EventState({ state, showLabel = false }: EventStateProps) {
+ const styles = useStyles2(getStyles);
+ const toolTip = t('alerting.central-alert-history.details.no-recognized-state', 'No recognized state');
if (!isGrafanaAlertState(state) && !isAlertStateWithReason(state)) {
return (
-
-
-
+ Unknown}
+ showLabel={Boolean(showLabel)}
+ iconColor={styles.warningColor}
+ />
);
}
const baseState = mapStateWithReasonToBaseState(state);
const reason = mapStateWithReasonToReason(state);
-
- switch (baseState) {
- case 'Normal':
- return (
-
-
-
- );
- case 'Alerting':
- return (
-
-
-
- );
- case 'NoData': //todo:change icon
- return (
-
-
- {/* no idea which icon to use */}
-
- );
- case 'Error':
- return (
-
-
-
- );
-
- case 'Pending':
- return (
-
-
-
- );
- default:
- return ;
+ interface StateConfig {
+ iconName: IconName;
+ iconColor: string;
+ tooltipContent: string;
+ labelText: ReactElement;
}
+ interface StateConfigMap {
+ [key: string]: StateConfig;
+ }
+ const stateConfig: StateConfigMap = {
+ Normal: {
+ iconName: 'check-circle',
+ iconColor: Boolean(reason) ? styles.warningColor : styles.normalColor,
+ tooltipContent: Boolean(reason) ? `Normal (${reason})` : 'Normal',
+ labelText: Normal,
+ },
+ Alerting: {
+ iconName: 'exclamation-circle',
+ iconColor: styles.alertingColor,
+ tooltipContent: 'Alerting',
+ labelText: Alerting,
+ },
+ NoData: {
+ iconName: 'exclamation-triangle',
+ iconColor: styles.warningColor,
+ tooltipContent: 'Insufficient data',
+ labelText: No data,
+ },
+ Error: {
+ iconName: 'exclamation-circle',
+ tooltipContent: 'Error',
+ iconColor: styles.warningColor,
+ labelText: Error,
+ },
+ Pending: {
+ iconName: 'circle',
+ iconColor: styles.warningColor,
+ tooltipContent: Boolean(reason) ? `Pending (${reason})` : 'Pending',
+ labelText: Pending,
+ },
+ };
+
+ const config = stateConfig[baseState] || { iconName: 'exclamation-triangle', tooltipContent: 'Unknown State' };
+ return ;
}
interface TimestampProps {
@@ -253,12 +322,16 @@ export const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`,
flexWrap: 'nowrap',
- borderBottom: `1px solid ${theme.colors.border.weak}`,
-
'&:hover': {
backgroundColor: theme.components.table.rowHoverBackground,
},
}),
+ collapsedHeader: css({
+ borderBottom: `1px solid ${theme.colors.border.weak}`,
+ }),
+ notCollapsedHeader: css({
+ borderBottom: 'none',
+ }),
collapseToggle: css({
background: 'none',
@@ -303,6 +376,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
display: 'block',
color: theme.colors.text.link,
}),
+ expandedRow: css({
+ padding: theme.spacing(2),
+ marginLeft: theme.spacing(2),
+ borderLeft: `1px solid ${theme.colors.border.weak}`,
+ }),
};
};
diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts
new file mode 100644
index 00000000000..43abc2c5d6b
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts
@@ -0,0 +1,53 @@
+import { dateTime } from '@grafana/data';
+
+import { LogRecord } from '../state-history/common';
+
+import { getPanelDataForRule } from './EventDetails';
+
+const initialTimeStamp = 1000000;
+const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; // actually, it doesn't matter what is here
+const records: LogRecord[] = [
+ {
+ timestamp: initialTimeStamp,
+ line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 1 } },
+ },
+ {
+ timestamp: initialTimeStamp + 1000,
+ line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID2' },
+ },
+ {
+ timestamp: initialTimeStamp + 2000,
+ line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID3' },
+ },
+ // not sorted by timestamp
+ {
+ timestamp: initialTimeStamp + 4000,
+ line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 8 } },
+ },
+ {
+ timestamp: initialTimeStamp + 3000,
+ line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } },
+ },
+ //duplicate record in the same timestamp
+ {
+ timestamp: initialTimeStamp + 3000,
+ line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } },
+ },
+ {
+ timestamp: initialTimeStamp + 5000,
+ line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } },
+ },
+];
+describe('getPanelDataForRule', () => {
+ it('should return correct panel data for a given rule (sorted by time and unique)', () => {
+ const result = getPanelDataForRule('ruleUID1', records, 'C');
+
+ expect(result.series[0].fields[0].values).toEqual([1000000, 1003000, 1004000, 1005000]);
+ expect(result.series[0].fields[1].values).toEqual([1, 0, 8, 0]);
+ expect(result.series[0].fields[0].type).toEqual('time');
+ expect(result.series[0].fields[1].type).toEqual('number');
+ expect(result.state).toEqual('Done');
+ expect(result.timeRange.from).toEqual(dateTime(1000000));
+ expect(result.timeRange.to).toEqual(dateTime(1005000));
+ });
+});
diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts
index 47c94d70367..f1503e356b2 100644
--- a/public/app/types/unified-alerting-dto.ts
+++ b/public/app/types/unified-alerting-dto.ts
@@ -47,9 +47,15 @@ export function mapStateWithReasonToReason(state: GrafanaAlertStateWithReason):
return match ? match[1] : '';
}
+type StateWithReasonToBaseStateReturnType = T extends GrafanaAlertStateWithReason
+ ? GrafanaAlertState
+ : T extends PromAlertingRuleState
+ ? PromAlertingRuleState
+ : never;
+
export function mapStateWithReasonToBaseState(
state: GrafanaAlertStateWithReason | PromAlertingRuleState
-): GrafanaAlertState | PromAlertingRuleState {
+): StateWithReasonToBaseStateReturnType {
if (isAlertStateWithReason(state)) {
const fields = state.split(' ');
return fields[0] as GrafanaAlertState;
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index dd85a4974ee..a5457b151f3 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -56,6 +56,39 @@
"pause": "Pause evaluation"
},
"alerting": {
+ "central-alert-history": {
+ "details": {
+ "annotations": "Annotations",
+ "error": "Error loading rule for this event.",
+ "loading": "Loading...",
+ "no-annotations": "No annotations",
+ "no-recognized-state": "No recognized state",
+ "no-values": "No values",
+ "not-found": "Rule not found for this event.",
+ "not-grafana-rule": "Rule is not a Grafana rule",
+ "number-transitions": "State transitions for selected period",
+ "state": {
+ "alerting": "Alerting",
+ "error": "Error",
+ "no-data": "No data",
+ "normal": "Normal",
+ "pending": "Pending"
+ },
+ "state-transitions": "State transition",
+ "unknown-event-state": "Unknown",
+ "unknown-rule": "Unknown",
+ "value-in-transition": "Value in transition"
+ },
+ "error": "Something went wrong loading the alert state history",
+ "filter": {
+ "info": {
+ "label1": "Filter events using label querying without spaces, ex:",
+ "label2": "Invalid use of spaces:",
+ "label3": "Valid use of spaces:",
+ "label4": "Filter alerts using label querying without braces, ex:"
+ }
+ }
+ },
"contact-points": {
"telegram": {
"parse-mode-warning-body": "If you use a <1>parse_mode1> option other than <3>None3>, truncation may result in an invalid message, causing the notification to fail. For longer messages, we recommend using an alternative contact method.",
@@ -173,17 +206,6 @@
"text": "No results found for your query"
}
},
- "central-alert-history": {
- "error": "Something went wrong loading the alert state history",
- "filter": {
- "info": {
- "label1": "Filter events using label querying without spaces, ex:",
- "label2": "Invalid use of spaces:",
- "label3": "Valid use of spaces:",
- "label4": "Filter alerts using label querying without braces, ex:"
- }
- }
- },
"clipboard-button": {
"inline-toast": {
"success": "Copied"
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index cac0b72010d..a1e078eaaa6 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -56,6 +56,39 @@
"pause": "Päūşę ęväľūäŧįőʼn"
},
"alerting": {
+ "central-alert-history": {
+ "details": {
+ "annotations": "Åʼnʼnőŧäŧįőʼnş",
+ "error": "Ēřřőř ľőäđįʼnģ řūľę ƒőř ŧĥįş ęvęʼnŧ.",
+ "loading": "Ŀőäđįʼnģ...",
+ "no-annotations": "Ńő äʼnʼnőŧäŧįőʼnş",
+ "no-recognized-state": "Ńő řęčőģʼnįžęđ şŧäŧę",
+ "no-values": "Ńő väľūęş",
+ "not-found": "Ŗūľę ʼnőŧ ƒőūʼnđ ƒőř ŧĥįş ęvęʼnŧ.",
+ "not-grafana-rule": "Ŗūľę įş ʼnőŧ ä Ğřäƒäʼnä řūľę",
+ "number-transitions": "Ŝŧäŧę ŧřäʼnşįŧįőʼnş ƒőř şęľęčŧęđ pęřįőđ",
+ "state": {
+ "alerting": "Åľęřŧįʼnģ",
+ "error": "Ēřřőř",
+ "no-data": "Ńő đäŧä",
+ "normal": "Ńőřmäľ",
+ "pending": "Pęʼnđįʼnģ"
+ },
+ "state-transitions": "Ŝŧäŧę ŧřäʼnşįŧįőʼn",
+ "unknown-event-state": "Ůʼnĸʼnőŵʼn",
+ "unknown-rule": "Ůʼnĸʼnőŵʼn",
+ "value-in-transition": "Väľūę įʼn ŧřäʼnşįŧįőʼn"
+ },
+ "error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
+ "filter": {
+ "info": {
+ "label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
+ "label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
+ "label3": "Väľįđ ūşę őƒ şpäčęş:",
+ "label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
+ }
+ }
+ },
"contact-points": {
"telegram": {
"parse-mode-warning-body": "Ĩƒ yőū ūşę ä <1>päřşę_mőđę1> őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę3>, ŧřūʼnčäŧįőʼn mäy řęşūľŧ įʼn äʼn įʼnväľįđ męşşäģę, čäūşįʼnģ ŧĥę ʼnőŧįƒįčäŧįőʼn ŧő ƒäįľ. Főř ľőʼnģęř męşşäģęş, ŵę řęčőmmęʼnđ ūşįʼnģ äʼn äľŧęřʼnäŧįvę čőʼnŧäčŧ męŧĥőđ.",
@@ -173,17 +206,6 @@
"text": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy"
}
},
- "central-alert-history": {
- "error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
- "filter": {
- "info": {
- "label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
- "label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
- "label3": "Väľįđ ūşę őƒ şpäčęş:",
- "label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
- }
- }
- },
"clipboard-button": {
"inline-toast": {
"success": "Cőpįęđ"