diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index e4757c8b993..21c8ae2a6a4 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -140,7 +140,7 @@ interface AnnotationValueProps { value: string; } -function AnnotationValue({ value }: AnnotationValueProps) { +export function AnnotationValue({ value }: AnnotationValueProps) { const needsExternalLink = value && value.startsWith('http'); const tokenizeValue = ; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx index 6e52b27e1d2..9a1aa40e9cf 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx @@ -64,7 +64,12 @@ export const CentralAlertHistoryScene = () => { new SceneTimePicker({}), new SceneRefreshPicker({}), ], - $timeRange: new SceneTimeRange({}), //needed for using the time range sync in the url + // use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events, + // and using a wider time range might lead to showing gaps in the events list and the chart. + $timeRange: new SceneTimeRange({ + from: 'now-1h', + to: 'now', + }), $variables: new SceneVariableSet({ variables: [filterVariable], }), @@ -170,16 +175,16 @@ export const FilterInfo = () => { - + 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_mode option other than <3>None, 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őđę őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę, ŧřūʼ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įęđ"