Alerting: Central alert history (part1) (#88593)

* WIP

* Add barchart panel with scenes

* Fix timerange in barchart panel

* Refactor: component names

* Remove not used css styles and rename panel title

* Remove unnecessary HistoryEventsListObject class and update text in labels filter

* add padding top for filter

* Add translations

* update limit labels constant

* Update showing state reason

* Fix scene object

* Address review comments

* Update icons

* use endpoints instead of the autogenerated hook

* Address review comments

* Add tooltip for alert name

* use private polling interval

* fix autogenerated translations

* Address pr rewview comments

* Address review comments

* Update text in placeholder

* Rename variable and remove spaces in Trans children
This commit is contained in:
Sonia Aguilar 2024-06-17 14:54:15 +02:00 committed by GitHub
parent 32d21356b9
commit e75fbe10ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 615 additions and 19 deletions

View File

@ -1646,8 +1646,7 @@ exports[`better eslint`] = {
],
"public/app/features/alerting/unified/components/AlertLabels.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]

View File

@ -408,6 +408,16 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts from an Alertmanager instance", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
}
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "History",
SubTitle: "History of events that were generated by your Grafana-managed alert rules. Silences and Mute timings are ignored.",
Id: "alerts-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
if c.SignedInUser.GetOrgRole() == org.RoleAdmin {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",

View File

@ -166,6 +166,19 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
() => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups')
),
},
{
path: '/alerting/history/',
roles: evaluateAccess([
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstancesExternalRead,
]),
component: importAlertingComponent(
() =>
import(
/* webpackChunkName: "HistoryPage" */ 'app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage'
)
),
},
{
path: '/alerting/new/:type?',
pageClass: 'page-alerting',

View File

@ -4,7 +4,7 @@ import { alertingApi } from './alertingApi';
export const stateHistoryApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getRuleHistory: build.query<DataFrameJSON, { ruleUid: string; from?: number; to?: number; limit?: number }>({
getRuleHistory: build.query<DataFrameJSON, { ruleUid?: string; from?: number; to?: number; limit?: number }>({
query: ({ ruleUid, from, to, limit = 100 }) => ({
url: '/api/v1/rules/history',
params: { ruleUID: ruleUid, from, to, limit },

View File

@ -5,6 +5,7 @@ import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, getTagColorsFromName, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { isPrivateLabel } from '../utils/labels';
@ -28,6 +29,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
const commonLabelsCount = Object.keys(commonLabels).length;
const hasCommonLabels = commonLabelsCount > 0;
const tooltip = t('alert-labels.button.show.tooltip', 'Show common labels');
return (
<div className={styles.wrapper} role="list" aria-label="Labels">
@ -39,7 +41,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
variant="secondary"
fill="text"
onClick={() => setShowCommonLabels(true)}
tooltip="Show common labels"
tooltip={tooltip}
tooltipPlacement="top"
size="sm"
>
@ -54,7 +56,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
tooltipPlacement="top"
size="sm"
>
Hide common labels
<Trans i18nKey="alert-labels.button.hide">Hide common labels</Trans>
</Button>
)}
</div>

View File

@ -0,0 +1,383 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMeasure } from 'react-use';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
import {
Alert,
Button,
Field,
Icon,
Input,
Label,
LoadingBar,
Stack,
Text,
Tooltip,
useStyles2,
withErrorBoundary,
} from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { Trans, t } from 'app/core/internationalization';
import {
GrafanaAlertStateWithReason,
isAlertStateWithReason,
isGrafanaAlertState,
mapStateWithReasonToBaseState,
mapStateWithReasonToReason,
} from 'app/types/unified-alerting-dto';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { stringifyErrorLike } from '../../../utils/misc';
import { hashLabelsOrAnnotations } from '../../../utils/rule-id';
import { AlertLabels } from '../../AlertLabels';
import { CollapseToggle } from '../../CollapseToggle';
import { LogRecord } from '../state-history/common';
import { useRuleHistoryRecords } from '../state-history/useRuleHistoryRecords';
const LIMIT_EVENTS = 250;
const HistoryEventsList = ({ timeRange }: { timeRange?: TimeRange }) => {
const styles = useStyles2(getStyles);
// Filter state
const [eventsFilter, setEventsFilter] = useState('');
// form for filter fields
const { register, handleSubmit, reset } = useForm({ defaultValues: { query: '' } }); // form for search field
const from = timeRange?.from.unix();
const to = timeRange?.to.unix();
const onFilterCleared = useCallback(() => {
setEventsFilter('');
reset();
}, [setEventsFilter, reset]);
const {
data: stateHistory,
isLoading,
isError,
error,
} = stateHistoryApi.endpoints.getRuleHistory.useQuery(
{
from: from,
to: to,
limit: LIMIT_EVENTS,
},
{
refetchOnFocus: true,
refetchOnReconnect: true,
}
);
const { historyRecords } = useRuleHistoryRecords(stateHistory, eventsFilter);
if (isError) {
return <HistoryErrorMessage error={error} />;
}
return (
<Stack direction="column" gap={1}>
<div className={styles.labelsFilter}>
<form onSubmit={handleSubmit((data) => setEventsFilter(data.query))}>
<SearchFieldInput
{...register('query')}
showClearFilterSuffix={!!eventsFilter}
onClearFilterClick={onFilterCleared}
/>
<input type="submit" hidden />
</form>
</div>
<LoadingIndicator visible={isLoading} />
<HistoryLogEvents logRecords={historyRecords} />
</Stack>
);
};
// todo: this function has been copied from RuleList.v2.tsx, should be moved to a shared location
const LoadingIndicator = ({ visible = false }) => {
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>;
};
interface HistoryLogEventsProps {
logRecords: LogRecord[];
}
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
// display log records
return (
<ul>
{logRecords.map((record) => {
return <EventRow key={record.timestamp + hashLabelsOrAnnotations(record.line.labels ?? {})} record={record} />;
})}
</ul>
);
}
interface HistoryErrorMessageProps {
error: unknown;
}
function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
if (isFetchError(error) && error.status === 404) {
return <EntityNotFound entity="History" />;
}
const title = t('central-alert-history.error', 'Something went wrong loading the alert state history');
return <Alert title={title}>{stringifyErrorLike(error)}</Alert>;
}
interface SearchFieldInputProps {
showClearFilterSuffix: boolean;
onClearFilterClick: () => void;
}
const SearchFieldInput = React.forwardRef<HTMLInputElement, SearchFieldInputProps>(
({ showClearFilterSuffix, onClearFilterClick, ...rest }: SearchFieldInputProps, ref) => {
const placeholder = t('central-alert-history.filter.placeholder', 'Filter events in the list with labels');
return (
<Field
label={
<Label htmlFor="eventsSearchInput">
<Stack gap={0.5}>
<span>
<Trans i18nKey="central-alert-history.filter.label">Filter events</Trans>
</span>
</Stack>
</Label>
}
>
<Input
id="eventsSearchInput"
prefix={<Icon name="search" />}
suffix={
showClearFilterSuffix && (
<Button fill="text" icon="times" size="sm" onClick={onClearFilterClick}>
<Trans i18nKey="central-alert-history.filter.button.clear">Clear</Trans>
</Button>
)
}
placeholder={placeholder}
ref={ref}
{...rest}
/>
</Field>
);
}
);
SearchFieldInput.displayName = 'SearchFieldInput';
function EventRow({ record }: { record: LogRecord }) {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
return (
<div>
<div className={styles.header} data-testid="rule-group-header">
<CollapseToggle
size="sm"
className={styles.collapseToggle}
isCollapsed={isCollapsed}
onToggle={setIsCollapsed}
/>
<Stack gap={0.5} direction={'row'} alignItems={'center'}>
<div className={styles.timeCol}>
<Timestamp time={record.timestamp} />
</div>
<div className={styles.transitionCol}>
<EventTransition previous={record.line.previous} current={record.line.current} />
</div>
<div className={styles.alertNameCol}>
{record.line.labels ? <AlertRuleName labels={record.line.labels} ruleUID={record.line.ruleUID} /> : null}
</div>
<div className={styles.labelsCol}>
<AlertLabels labels={record.line.labels ?? {}} size="xs" />
</div>
</Stack>
</div>
</div>
);
}
function AlertRuleName({ labels, ruleUID }: { labels: Record<string, string>; ruleUID?: string }) {
const styles = useStyles2(getStyles);
const alertRuleName = labels['alertname'];
if (!ruleUID) {
return <Text>{alertRuleName}</Text>;
}
return (
<Tooltip content={alertRuleName ?? ''}>
<a
href={`/alerting/${GRAFANA_RULES_SOURCE_NAME}/${ruleUID}/view?returnTo=${encodeURIComponent('/alerting/history')}`}
className={styles.alertName}
>
{alertRuleName}
</a>
</Tooltip>
);
}
interface EventTransitionProps {
previous: GrafanaAlertStateWithReason;
current: GrafanaAlertStateWithReason;
}
function EventTransition({ previous, current }: EventTransitionProps) {
return (
<Stack gap={0.5} direction={'row'}>
<EventState state={previous} />
<Icon name="arrow-right" size="lg" />
<EventState state={current} />
</Stack>
);
}
function EventState({ state }: { state: GrafanaAlertStateWithReason }) {
const styles = useStyles2(getStyles);
if (!isGrafanaAlertState(state) && !isAlertStateWithReason(state)) {
return (
<Tooltip content={'No recognized state'}>
<Icon name="exclamation-triangle" size="md" />
</Tooltip>
);
}
const baseState = mapStateWithReasonToBaseState(state);
const reason = mapStateWithReasonToReason(state);
switch (baseState) {
case 'Normal':
return (
<Tooltip content={Boolean(reason) ? `Normal (${reason})` : 'Normal'}>
<Icon name="check-circle" size="md" className={Boolean(reason) ? styles.warningColor : styles.normalColor} />
</Tooltip>
);
case 'Alerting':
return (
<Tooltip content={'Alerting'}>
<Icon name="exclamation-circle" size="md" className={styles.alertingColor} />
</Tooltip>
);
case 'NoData': //todo:change icon
return (
<Tooltip content={'Insufficient data'}>
<Icon name="exclamation-triangle" size="md" className={styles.warningColor} />
{/* no idea which icon to use */}
</Tooltip>
);
case 'Error':
return (
<Tooltip content={'Error'}>
<Icon name="exclamation-circle" size="md" />
</Tooltip>
);
case 'Pending':
return (
<Tooltip content={Boolean(reason) ? `Pending (${reason})` : 'Pending'}>
<Icon name="circle" size="md" className={styles.warningColor} />
</Tooltip>
);
default:
return <Icon name="exclamation-triangle" size="md" />;
}
}
interface TimestampProps {
time: number; // epoch timestamp
}
const Timestamp = ({ time }: TimestampProps) => {
const dateTime = new Date(time);
const formattedDate = dateTime.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return (
<Text variant="body" weight="light">
{formattedDate}
</Text>
);
};
export default withErrorBoundary(HistoryEventsList, { style: 'page' });
export const getStyles = (theme: GrafanaTheme2) => {
return {
header: css({
display: 'flex',
flexDirection: 'row',
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,
},
}),
collapseToggle: css({
background: 'none',
border: 'none',
marginTop: `-${theme.spacing(1)}`,
marginBottom: `-${theme.spacing(1)}`,
svg: {
marginBottom: 0,
},
}),
normalColor: css({
fill: theme.colors.success.text,
}),
warningColor: css({
fill: theme.colors.warning.text,
}),
alertingColor: css({
fill: theme.colors.error.text,
}),
timeCol: css({
width: '150px',
}),
transitionCol: css({
width: '80px',
}),
alertNameCol: css({
width: '300px',
}),
labelsCol: css({
display: 'flex',
overflow: 'hidden',
alignItems: 'center',
paddingRight: theme.spacing(2),
flex: 1,
}),
alertName: css({
whiteSpace: 'nowrap',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
color: theme.colors.text.link,
}),
labelsFilter: css({
width: '100%',
paddingTop: theme.spacing(4),
}),
};
};
export class HistoryEventsListObject extends SceneObjectBase {
public static Component = HistoryEventsListObjectRenderer;
}
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
return <HistoryEventsList timeRange={timeRange} />;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
const HistoryPage = () => {
return (
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
<CentralAlertHistoryScene />
</AlertingPageWrapper>
);
};
export default withErrorBoundary(HistoryPage, { style: 'page' });

View File

@ -0,0 +1,124 @@
import React from 'react';
import { getDataSourceSrv } from '@grafana/runtime';
import {
EmbeddedScene,
PanelBuilders,
SceneControlsSpacer,
SceneFlexItem,
SceneFlexLayout,
SceneQueryRunner,
SceneReactObject,
SceneRefreshPicker,
SceneTimePicker,
} from '@grafana/scenes';
import {
GraphDrawStyle,
GraphGradientMode,
LegendDisplayMode,
LineInterpolation,
ScaleDistribution,
StackingMode,
TooltipDisplayMode,
VisibilityMode,
} from '@grafana/schema/dist/esm/index';
import { DataSourceInformation, PANEL_STYLES } from '../../../home/Insights';
import { SectionSubheader } from '../../../insights/SectionSubheader';
import { HistoryEventsListObjectRenderer } from './CentralAlertHistory';
export const CentralAlertHistoryScene = () => {
const dataSourceSrv = getDataSourceSrv();
const alertStateHistoryDatasource: DataSourceInformation = {
type: 'loki',
uid: 'grafanacloud-alert-state-history',
settings: undefined,
};
alertStateHistoryDatasource.settings = dataSourceSrv.getInstanceSettings(alertStateHistoryDatasource.uid);
const scene = new EmbeddedScene({
controls: [new SceneControlsSpacer(), new SceneTimePicker({}), new SceneRefreshPicker({})],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: getEventsSceneObject(alertStateHistoryDatasource),
}),
new SceneFlexItem({
body: new SceneReactObject({
component: HistoryEventsListObjectRenderer,
}),
}),
],
}),
});
return <scene.Component model={scene} />;
};
function getEventsSceneObject(ashDs: DataSourceInformation) {
return new EmbeddedScene({
controls: [
new SceneReactObject({
component: SectionSubheader,
}),
],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: new SceneFlexLayout({
children: [getEventsScenesFlexItem(ashDs)],
}),
}),
],
}),
});
}
function getSceneQuery(datasource: DataSourceInformation) {
const query = new SceneQueryRunner({
datasource,
queries: [
{
refId: 'A',
expr: 'count_over_time({from="state-history"} |= `` [$__auto])',
queryType: 'range',
step: '10s',
},
],
});
return query;
}
export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
return new SceneFlexItem({
...PANEL_STYLES,
body: PanelBuilders.timeseries()
.setTitle('Events')
.setDescription('Alert events during the period of time.')
.setData(getSceneQuery(datasource))
.setColor({ mode: 'continuous-BlPu' })
.setCustomFieldConfig('fillOpacity', 100)
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('lineInterpolation', LineInterpolation.Linear)
.setCustomFieldConfig('lineWidth', 1)
.setCustomFieldConfig('barAlignment', 0)
.setCustomFieldConfig('spanNulls', false)
.setCustomFieldConfig('insertNulls', false)
.setCustomFieldConfig('showPoints', VisibilityMode.Auto)
.setCustomFieldConfig('pointSize', 5)
.setCustomFieldConfig('stacking', { mode: StackingMode.None, group: 'A' })
.setCustomFieldConfig('gradientMode', GraphGradientMode.Hue)
.setCustomFieldConfig('scaleDistribution', { type: ScaleDistribution.Linear })
.setOption('legend', { showLegend: false, displayMode: LegendDisplayMode.Hidden })
.setOption('tooltip', { mode: TooltipDisplayMode.Single })
.setNoValue('No events found')
.build(),
});
}

View File

@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { DataFrame, dateTime, GrafanaTheme2, TimeRange } from '@grafana/data';
import { Alert, Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui';
import { Alert, Button, Field, Icon, Input, Label, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { combineMatcherStrings } from '../../../utils/alertmanager';

View File

@ -7,6 +7,7 @@ export interface Line {
current: GrafanaAlertStateWithReason;
values?: Record<string, number>;
labels?: Record<string, string>;
ruleUID?: string;
}
export interface LogRecord {

View File

@ -240,7 +240,7 @@ export function hashRule(rule: Rule): string {
throw new Error('only recording and alerting rules can be hashed');
}
function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
export function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0])));
}

View File

@ -149,6 +149,13 @@ export const navIndex: NavIndex = {
icon: 'layer-group',
url: '/alerting/groups',
},
{
id: 'history',
text: 'History',
subTitle: 'Alert state history',
icon: 'history',
url: '/alerting/history',
},
{
id: 'alerting-admin',
text: 'Settings',

View File

@ -42,6 +42,11 @@ export function isAlertStateWithReason(
return state !== null && state !== undefined && !propAlertingRuleStateValues.includes(state);
}
export function mapStateWithReasonToReason(state: GrafanaAlertStateWithReason): string {
const match = state.match(/\((.*?)\)/);
return match ? match[1] : '';
}
export function mapStateWithReasonToBaseState(
state: GrafanaAlertStateWithReason | PromAlertingRuleState
): GrafanaAlertState | PromAlertingRuleState {

View File

@ -25,6 +25,14 @@
"user": "User"
}
},
"alert-labels": {
"button": {
"hide": "Hide common labels",
"show": {
"tooltip": "Show common labels"
}
}
},
"alert-rule-form": {
"evaluation-behaviour": {
"description": {
@ -84,15 +92,15 @@
},
"counts": {
"alertRule_one": "{{count}} alert rule",
"alertRule_other": "{{count}} alert rules",
"alertRule_other": "{{count}} alert rule",
"dashboard_one": "{{count}} dashboard",
"dashboard_other": "{{count}} dashboards",
"dashboard_other": "{{count}} dashboard",
"folder_one": "{{count}} folder",
"folder_other": "{{count}} folders",
"folder_other": "{{count}} folder",
"libraryPanel_one": "{{count}} library panel",
"libraryPanel_other": "{{count}} library panels",
"libraryPanel_other": "{{count}} library panel",
"total_one": "{{count}} item",
"total_other": "{{count}} items"
"total_other": "{{count}} item"
},
"dashboards-tree": {
"collapse-folder-button": "Collapse folder {{title}}",
@ -138,6 +146,16 @@
"text": "No results found for your query"
}
},
"central-alert-history": {
"error": "Something went wrong loading the alert state history",
"filter": {
"button": {
"clear": "Clear"
},
"label": "Filter events",
"placeholder": "Filter events in the list with labels"
}
},
"clipboard-button": {
"inline-toast": {
"success": "Copied"
@ -758,7 +776,7 @@
},
"modal": {
"body_one": "This panel is being used in {{count}} dashboard. Please choose which dashboard to view the panel in:",
"body_other": "This panel is being used in {{count}} dashboards. Please choose which dashboard to view the panel in:",
"body_other": "This panel is being used in {{count}} dashboard. Please choose which dashboard to view the panel in:",
"button-cancel": "Cancel",
"button-view-panel1": "View panel in {{label}}...",
"button-view-panel2": "View panel in dashboard...",

View File

@ -25,6 +25,14 @@
"user": "Ůşęř"
}
},
"alert-labels": {
"button": {
"hide": "Ħįđę čőmmőʼn ľäþęľş",
"show": {
"tooltip": "Ŝĥőŵ čőmmőʼn ľäþęľş"
}
}
},
"alert-rule-form": {
"evaluation-behaviour": {
"description": {
@ -84,15 +92,15 @@
},
"counts": {
"alertRule_one": "{{count}} äľęřŧ řūľę",
"alertRule_other": "{{count}} äľęřŧ řūľęş",
"alertRule_other": "{{count}} äľęřŧ řūľę",
"dashboard_one": "{{count}} đäşĥþőäřđ",
"dashboard_other": "{{count}} đäşĥþőäřđş",
"dashboard_other": "{{count}} đäşĥþőäřđ",
"folder_one": "{{count}} ƒőľđęř",
"folder_other": "{{count}} ƒőľđęřş",
"folder_other": "{{count}} ƒőľđęř",
"libraryPanel_one": "{{count}} ľįþřäřy päʼnęľ",
"libraryPanel_other": "{{count}} ľįþřäřy päʼnęľş",
"libraryPanel_other": "{{count}} ľįþřäřy päʼnęľ",
"total_one": "{{count}} įŧęm",
"total_other": "{{count}} įŧęmş"
"total_other": "{{count}} įŧęm"
},
"dashboards-tree": {
"collapse-folder-button": "Cőľľäpşę ƒőľđęř {{title}}",
@ -138,6 +146,16 @@
"text": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy"
}
},
"central-alert-history": {
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
"filter": {
"button": {
"clear": "Cľęäř"
},
"label": "Fįľŧęř ęvęʼnŧş",
"placeholder": "Fįľŧęř ęvęʼnŧş įʼn ŧĥę ľįşŧ ŵįŧĥ ľäþęľş"
}
},
"clipboard-button": {
"inline-toast": {
"success": "Cőpįęđ"
@ -758,7 +776,7 @@
},
"modal": {
"body_one": "Ŧĥįş päʼnęľ įş þęįʼnģ ūşęđ įʼn {{count}} đäşĥþőäřđ. Pľęäşę čĥőőşę ŵĥįčĥ đäşĥþőäřđ ŧő vįęŵ ŧĥę päʼnęľ įʼn:",
"body_other": "Ŧĥįş päʼnęľ įş þęįʼnģ ūşęđ įʼn {{count}} đäşĥþőäřđş. Pľęäşę čĥőőşę ŵĥįčĥ đäşĥþőäřđ ŧő vįęŵ ŧĥę päʼnęľ įʼn:",
"body_other": "Ŧĥįş päʼnęľ įş þęįʼnģ ūşęđ įʼn {{count}} đäşĥþőäřđ. Pľęäşę čĥőőşę ŵĥįčĥ đäşĥþőäřđ ŧő vįęŵ ŧĥę päʼnęľ įʼn:",
"button-cancel": "Cäʼnčęľ",
"button-view-panel1": "Vįęŵ päʼnęľ įʼn {{label}}...",
"button-view-panel2": "Vįęŵ päʼnęľ įʼn đäşĥþőäřđ...",