Skip to content

Commit

Permalink
add history for dashboards and metrics
Browse files Browse the repository at this point in the history
  • Loading branch information
Vasiliy Trushin committed Jan 16, 2025
1 parent e0bd51c commit 4dfb193
Show file tree
Hide file tree
Showing 38 changed files with 799 additions and 173 deletions.
56 changes: 55 additions & 1 deletion statshouse-ui/src/admin/api/saveMetric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import { IBackendKind, IBackendMetric, IMetric } from '../models/metric';
import { isNotNil } from '@/common/helpers';
import { IBackendKind, IBackendMetric, IKind, IMetric, ITag } from '../models/metric';
import { freeKeyPrefix } from '@/url2';

export function saveMetric(metric: IMetric) {
const body: IBackendMetric = {
Expand Down Expand Up @@ -77,3 +79,55 @@ export function resetMetricFlood(metricName: string) {
}
});
}

export const fetchMetric = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch: ${url}`);
}
return response.json();
};

export const fetchAndProcessMetric = async (url: string) => {
const {
data: { metric },
} = await fetchMetric(url);

const tags_draft: ITag[] = Object.entries(metric.tags_draft ?? {})
.map(([, t]) => t as ITag)
.filter(isNotNil);
tags_draft.sort((a, b) => (b.name < a.name ? 1 : b.name === a.name ? 0 : -1));

return {
id: metric.metric_id === undefined ? 0 : metric.metric_id,
name: metric.name,
description: metric.description,
kind: (metric.kind.endsWith('_p') ? metric.kind.replace('_p', '') : metric.kind) as IKind,
stringTopName: metric.string_top_name === undefined ? '' : metric.string_top_name,
stringTopDescription: metric.string_top_description === undefined ? '' : metric.string_top_description,
weight: metric.weight === undefined ? 1 : metric.weight,
resolution: metric.resolution === undefined ? 1 : metric.resolution,
visible: metric.visible === undefined ? false : metric.visible,
withPercentiles: metric.kind.endsWith('_p'),
tags: metric.tags.map((tag: ITag, index: number) => ({
name: tag.name === undefined || tag.name === `key${index}` ? '' : tag.name,
alias: tag.description === undefined ? '' : tag.description,
customMapping: tag.value_comments
? Object.entries(tag.value_comments).map(([from, to]) => ({
from,
to,
}))
: [],
isRaw: tag.raw,
raw_kind: tag.raw_kind,
})),
tags_draft,
tagsSize: metric.tags.length,
pre_key_tag_id: metric.pre_key_tag_id && freeKeyPrefix(metric.pre_key_tag_id),
pre_key_from: metric.pre_key_from,
metric_type: metric.metric_type,
version: metric.version,
group_id: metric.group_id,
fair_key_tag_ids: metric.fair_key_tag_ids,
};
};
204 changes: 123 additions & 81 deletions statshouse-ui/src/admin/pages/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,28 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { IBackendMetric, IKind, IMetric, ITag, ITagAlias } from '../models/metric';
import { Dispatch, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { IBackendMetric, IKind, IMetric, ITagAlias } from '../models/metric';
import { MetricFormValuesContext, MetricFormValuesStorage } from '../storages/MetricFormValues';
import { ReactComponent as SVGTrash } from 'bootstrap-icons/icons/trash.svg';
import { resetMetricFlood, saveMetric } from '../api/saveMetric';
import { IActions } from '../storages/MetricFormValues/reducer';
import { useStore } from '@/store';
import { RawValueKind } from '@/view/api';
import { freeKeyPrefix } from '@/url/queryParams';
import { METRIC_TYPE, METRIC_TYPE_DESCRIPTION, MetricType } from '@/api/enum';
import { maxTagsSize } from '@/common/settings';
import { Button } from '@/components/UI';
import { ReactComponent as SVGPlusLg } from 'bootstrap-icons/icons/plus-lg.svg';
import { ReactComponent as SVGDashLg } from 'bootstrap-icons/icons/dash-lg.svg';
import { isNotNil, toNumber } from '@/common/helpers';
import { isHistoricalVersion, toNumber } from '@/common/helpers';
import { dequal } from 'dequal/lite';
import { produce } from 'immer';
import { TagDraft } from './TagDraft';
import { formatInputDate } from '@/view/utils2';
import { Select } from '@/components/Select';
import { MetricHistoryList } from '@/components2';
import { fetchAndProcessMetric, resetMetricFlood, saveMetric } from '../api/saveMetric';
import { StickyTop } from '@/components2/StickyTop';

const { clearMetricsMeta } = useStore.getState();

Expand All @@ -34,80 +34,121 @@ const METRIC_TYPE_KEYS: MetricType[] = Object.values(METRIC_TYPE) as MetricType[
export function FormPage(props: { yAxisSize: number; adminMode: boolean }) {
const { yAxisSize, adminMode } = props;
const { metricName } = useParams();
const [initMetric, setInitMetric] = React.useState<Partial<IMetric> | null>(null);
React.useEffect(() => {
fetch(`/api/metric?s=${metricName}`)
.then<{ data: { metric: IBackendMetric } }>((res) => res.json())
.then(({ data: { metric } }) => {
const tags_draft: ITag[] = Object.entries(metric.tags_draft ?? {})
.map(([, t]) => t)
.filter(isNotNil);
tags_draft.sort((a, b) => (b.name < a.name ? 1 : b.name === a.name ? 0 : -1));

const [searchParams] = useSearchParams();
const historicalMetricVersion = useMemo(() => searchParams.get('mv'), [searchParams]);

const [initMetric, setInitMetric] = useState<Partial<IMetric> | null>(null);
const [isShowHistory, setIsShowHistory] = useState(false);

const isHistoricalMetric = useMemo(
() =>
!!initMetric?.version &&
!!historicalMetricVersion &&
isHistoricalVersion(initMetric.version, Number(historicalMetricVersion)),
[initMetric?.version, historicalMetricVersion]
);

const loadMetric = async () => {
try {
if (initMetric?.version && initMetric?.id && isHistoricalMetric) {
const currentMetric = await fetchAndProcessMetric(`/api/metric?s=${metricName}`);
const historicalMetricData = await fetchAndProcessMetric(
`/api/metric?id=${initMetric.id}&ver=${initMetric.version}`
);

setInitMetric({
id: metric.metric_id === undefined ? 0 : metric.metric_id,
name: metric.name,
description: metric.description,
kind: (metric.kind.endsWith('_p') ? metric.kind.replace('_p', '') : metric.kind) as IKind,
stringTopName: metric.string_top_name === undefined ? '' : metric.string_top_name,
stringTopDescription: metric.string_top_description === undefined ? '' : metric.string_top_description,
weight: metric.weight === undefined ? 1 : metric.weight,
resolution: metric.resolution === undefined ? 1 : metric.resolution,
visible: metric.visible === undefined ? false : metric.visible,
withPercentiles: metric.kind.endsWith('_p'),
tags: metric.tags.map((tag: ITag, index) => ({
name: tag.name === undefined || tag.name === `key${index}` ? '' : tag.name, // now API sends undefined for canonical names, but this can change in the future, so we keep the code
alias: tag.description === undefined ? '' : tag.description,
customMapping: tag.value_comments
? Object.entries(tag.value_comments).map(([from, to]) => ({
from,
to,
}))
: [],
isRaw: tag.raw,
raw_kind: tag.raw_kind,
})),
tags_draft,
tagsSize: metric.tags.length,
pre_key_tag_id: metric.pre_key_tag_id && freeKeyPrefix(metric.pre_key_tag_id),
pre_key_from: metric.pre_key_from,
metric_type: metric.metric_type,
version: metric.version,
group_id: metric.group_id,
fair_key_tag_ids: metric.fair_key_tag_ids,
...historicalMetricData,
version: currentMetric.version || historicalMetricData.version,
});
});
}, [metricName]);
} else {
const metricData = await fetchAndProcessMetric(`/api/metric?s=${metricName}`);
setInitMetric(metricData);
}
} catch (error) {
console.error(error);

Check warning on line 69 in statshouse-ui/src/admin/pages/FormPage.tsx

View workflow job for this annotation

GitHub Actions / ci-ui

Unexpected console statement
}
};

useEffect(() => {
if (metricName) {
loadMetric();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricName, isHistoricalMetric]);

// update document title
React.useEffect(() => {
useEffect(() => {
document.title = `${metricName + ': edit'} — StatsHouse`;
}, [metricName]);

const handleShowHistory = () => {
setIsShowHistory(true);
};

const handleShowEdit = () => {
setIsShowHistory(false);
};

return (
<div className="container-xl pt-3 pb-3" style={{ paddingLeft: `${yAxisSize}px` }}>
<h6 className="overflow-force-wrap font-monospace fw-bold me-3 mb-3" title={`ID: ${initMetric?.id || '?'}`}>
{metricName}
<>
<span className="text-secondary me-4">: edit</span>
<Link className="text-decoration-none fw-normal small" to={`../../view?s=${metricName}`}>
view
</Link>
</>
</h6>
{metricName && !initMetric ? (
<div className="d-flex justify-content-center align-items-center mt-5">
<div className="spinner-border text-secondary" role="status">
<span className="visually-hidden">Loading...</span>
<StickyTop>
<div className="d-flex">
<div className="my-auto">
<h6
className="overflow-force-wrap font-monospace fw-bold me-3 my-auto"
title={`ID: ${initMetric?.id || '?'}`}
>
{metricName}
<span
className={`me-4 ${isShowHistory ? 'text-primary fw-normal small cursor-pointer' : 'text-secondary'}`}
style={{ cursor: isShowHistory ? 'pointer' : 'default' }}
onClick={handleShowEdit}
>
: edit
</span>
<span
className={`me-4 ${isShowHistory ? 'text-secondary' : 'text-primary fw-normal small cursor-pointer'}`}
style={{ cursor: isShowHistory ? 'default' : 'pointer' }}
onClick={handleShowHistory}
>
history
</span>
<Link className="text-decoration-none fw-normal small" to={`../../view?s=${metricName}`}>
view
</Link>
</h6>
</div>

{isHistoricalMetric && (
<div className="border border-danger rounded px-4 py-2 font-monospace fw-bold text-danger ms-auto">
Historical version
</div>
)}
</div>
) : (
<MetricFormValuesStorage initialMetric={initMetric || {}}>
</StickyTop>

<MetricFormValuesStorage initialMetric={initMetric || {}}>
{metricName && !initMetric ? (
<div className="d-flex justify-content-center align-items-center mt-5">
<div className="spinner-border text-secondary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : isShowHistory && initMetric?.id ? (
<MetricHistoryList
metricId={initMetric.id.toString()}
metricName={metricName || ''}
handleShowEdit={handleShowEdit}
/>
) : (
<EditForm
isReadonly={false} // !!metricName && metricName.startsWith('__')
adminMode={adminMode}
isHistoricalMetric={isHistoricalMetric}
/>
</MetricFormValuesStorage>
)}
)}
</MetricFormValuesStorage>
</div>
);
}
Expand All @@ -119,10 +160,10 @@ const kindConfig = [
{ label: 'Mixed', value: 'mixed' },
];

export function EditForm(props: { isReadonly: boolean; adminMode: boolean }) {
const { isReadonly, adminMode } = props;
const { values, dispatch } = React.useContext(MetricFormValuesContext);
const { onSubmit, isRunning, error, success } = useSubmit(values, dispatch);
export function EditForm(props: { isReadonly: boolean; adminMode: boolean; isHistoricalMetric: boolean }) {
const { isReadonly, adminMode, isHistoricalMetric } = props;
const { values, dispatch } = useContext(MetricFormValuesContext);
const { onSubmit, isRunning, error, success } = useSubmit(values, dispatch, isHistoricalMetric);
const { onSubmitFlood, isRunningFlood, errorFlood, successFlood } = useSubmitResetFlood(values.name);
const preKeyFromString = useMemo<string>(
() => (values.pre_key_from ? formatInputDate(values.pre_key_from) : ''),
Expand Down Expand Up @@ -829,15 +870,15 @@ function AliasField(props: {
);
}

function useSubmit(values: IMetric, dispatch: React.Dispatch<IActions>) {
const [isRunning, setRunning] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | null>(null);
const [success, setSuccess] = React.useState<string | null>(null);
function useSubmit(values: IMetric, dispatch: Dispatch<IActions>, isHistoricalMetric: boolean) {
const [isRunning, setRunning] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);

const { metricName } = useParams();
const navigate = useNavigate();

const onSubmit = React.useCallback(() => {
const onSubmit = () => {
setError(null);
setSuccess(null);
setRunning(true);
Expand All @@ -850,7 +891,8 @@ function useSubmit(values: IMetric, dispatch: React.Dispatch<IActions>) {
.then<{ data: { metric: IBackendMetric } }>((res) => res)
.then((r) => {
dispatch({ version: r.data.metric.version });
if (metricName !== r.data.metric.name) {

if (metricName !== r.data.metric.name || isHistoricalMetric) {
navigate(`/admin/edit/${r.data.metric.name}`);
}
})
Expand All @@ -859,7 +901,7 @@ function useSubmit(values: IMetric, dispatch: React.Dispatch<IActions>) {
setRunning(false);
clearMetricsMeta(values.name);
});
}, [dispatch, metricName, navigate, values]);
};

return {
isRunning,
Expand All @@ -870,11 +912,11 @@ function useSubmit(values: IMetric, dispatch: React.Dispatch<IActions>) {
}

function useSubmitResetFlood(metricName: string) {
const [isRunningFlood, setRunningFlood] = React.useState<boolean>(false);
const [errorFlood, setErrorFlood] = React.useState<string | null>(null);
const [successFlood, setSuccessFlood] = React.useState<string | null>(null);
const [isRunningFlood, setRunningFlood] = useState<boolean>(false);
const [errorFlood, setErrorFlood] = useState<string | null>(null);
const [successFlood, setSuccessFlood] = useState<string | null>(null);

const onSubmitFlood = React.useCallback(() => {
const onSubmitFlood = useCallback(() => {
setErrorFlood(null);
setSuccessFlood(null);
setRunningFlood(true);
Expand Down
19 changes: 12 additions & 7 deletions statshouse-ui/src/admin/storages/MetricFormValues/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import * as React from 'react';
import { IMetric } from '../../models/metric';
import { IActions, initialValues, reducer } from './reducer';
import React, { Dispatch, FC, ReactNode, useEffect, useMemo, useReducer } from 'react';

interface IMetricFormValuesProps {
initialMetric?: Partial<IMetric>;
children?: React.ReactNode;
children?: ReactNode;
}

interface IMetricFormValuesContext {
values: IMetric;
dispatch: React.Dispatch<IActions>;
dispatch: Dispatch<IActions>;
}

// eslint-disable-next-line react-refresh/only-export-components
Expand All @@ -24,11 +24,16 @@ export const MetricFormValuesContext = React.createContext<IMetricFormValuesCont
dispatch: () => {},
});

export const MetricFormValuesStorage: React.FC<IMetricFormValuesProps> = (props) => {
export const MetricFormValuesStorage: FC<IMetricFormValuesProps> = (props) => {
const { initialMetric, children } = props;
// eslint-disable-next-line react-hooks/exhaustive-deps
const initValues = React.useMemo(() => ({ ...initialValues, ...initialMetric }), []);
const [values, dispatch] = React.useReducer(reducer, initValues);

const initValues = useMemo(() => ({ ...initialValues, ...initialMetric }), [initialMetric]);

const [values, dispatch] = useReducer(reducer, initValues);

useEffect(() => {
dispatch({ type: 'reset', newState: initValues });
}, [initValues, initialMetric]);

return <MetricFormValuesContext.Provider value={{ values, dispatch }}>{children}</MetricFormValuesContext.Provider>;
};
Loading

0 comments on commit 4dfb193

Please sign in to comment.