Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions statshouse-ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# StatsHouse UI

## Requirements

* Node 20+

## Working with remote instance of StatsHouse

It is useful to use remote backend, so you can work with actual data.
To do that you need to set up proxy to that instance.

### Setting up proxy

```shell
cp .env .env.local
echo REACT_APP_PROXY=https://statshouse.example.org/ >> .env.local
echo REACT_APP_PROXY_COOKIE="copy Cookie http response header from the instance" >> .env.local
echo REACT_APP_CONFIG="copy <meta name='setting' content='...'/> content from StatsHouse page" >> .env.local
```

## Run dev mode

```shell
npm install
npm run start
```
35 changes: 35 additions & 0 deletions statshouse-ui/src/api/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMemo } from 'react';
import { UndefinedInitialDataOptions, useQuery, UseQueryResult } from '@tanstack/react-query';
import { ExtendedError } from '@/api/api';

export const ApiProxyEndpoint = '/api/proxy';

export function getMarkdownProxiedUrl(
url: string,
enabled: boolean = true
): UndefinedInitialDataOptions<string | undefined, ExtendedError, string, [string, string]> {
return {
enabled,
queryKey: [ApiProxyEndpoint, url],
queryFn: async () => {
const proxiedUrl = `${ApiProxyEndpoint}?url=${encodeURIComponent(url)}`;
const result = await fetch(proxiedUrl, {
headers: {
Accept: 'text/markdown,text/plain',
},
});
const response = await result.text();
if (result.status !== 200) {
return '';
}
return response;
},
};
}

export function useApiProxy(url: string, enabled: boolean = true): UseQueryResult<string, ExtendedError> {
const options = useMemo(() => getMarkdownProxiedUrl(url, enabled), [enabled, url]);
return useQuery({
...options,
});
}
117 changes: 117 additions & 0 deletions statshouse-ui/src/components/Markdown/MarkdownRender.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright 2025 V Kontakte LLC
//
// This Source Code Form is subject to the terms of the Mozilla Public
// 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 { memo, useContext, useMemo } from 'react';
import ReactMarkdown, { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useApiProxy } from '@/api/proxy';
import { useMetricName } from '@/hooks/useMetricName';
import { OuterInfoContextProvider } from '@/contexts/OuterInfoContextProvider';
import { OuterInfoContext } from '@/contexts/OuterInfoContext';
import cn from 'classnames';
import css from './style.module.css';

const remarkPlugins = [remarkGfm];

function usePlaceholderInfo(href: string, value: string) {
const metric_name = useMetricName(true);
return useMemo(() => {
const valuePlaceholder = value.indexOf('⚡ ') === 0 ? value.replace('⚡ ', '') : value;
return href.replace(`%7Bvalue%7D`, valuePlaceholder).replace('%7Bmetric_name%7D', metric_name);
}, [href, metric_name, value]);
}

type MarkdownLoadUrlProps = {
description?: string;
href: string;
};
function MarkdownLoadUrl({ description, href }: MarkdownLoadUrlProps) {
const value = useContext(OuterInfoContext);
const loadHref = usePlaceholderInfo(href, value);
const query = useApiProxy(loadHref);
if (query.isLoading) {
return <p>loading...</p>;
}
if (!query.data) {
return <p>{value || description}</p>;
}
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={customLinkComponents}>
{query.data}
</ReactMarkdown>
);
}

const customLinkComponents: Components = {
a: function ATag({ node, ...props }) {
return <a {...props} target="_blank" rel="noopener noreferrer" />;
},
p: function PTag({ node, ...props }) {
const otherChildren = useMemo(() => {
if (Array.isArray(props.children)) {
const [_1, _2, ...ch] = props.children;
return ch;
}
return props.children;
}, [props.children]);
const description = useMemo(
() =>
(node &&
node.children?.[1]?.type === 'element' &&
node.children[1].children?.[0]?.type === 'text' &&
node.children[1].children?.[0]?.value) ||
undefined,
[node]
);
if (
node &&
node.children[0].type === 'text' &&
node.children[0].value === '$' &&
node.children[1].type === 'element' &&
node.children[1].tagName === 'a' &&
typeof node.children[1].properties.href === 'string'
) {
return (
<>
<MarkdownLoadUrl href={node.children[1].properties.href} description={description} />
<p>{otherChildren}</p>
</>
);
}
return <p {...props} />;
},
};

const inlineMarkdownAllowedElements = ['p', 'a'];

export type MarkdownRenderProps = {
children?: string;
className?: string;
value?: string;
inline?: boolean;
};

export const MarkdownRender = memo(function MarkdownRender({
children = '',
className,
value = '',
inline,
}: MarkdownRenderProps) {
return (
<OuterInfoContextProvider value={value}>
<div className={cn(css.markdown, inline && css.markdownPreview, className)}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={customLinkComponents}
allowedElements={inline ? inlineMarkdownAllowedElements : undefined}
unwrapDisallowed={inline}
>
{children}
</ReactMarkdown>
</div>
</OuterInfoContextProvider>
);
});
26 changes: 26 additions & 0 deletions statshouse-ui/src/components/Markdown/TooltipMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2025 V Kontakte LLC
//
// This Source Code Form is subject to the terms of the Mozilla Public
// 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 { memo } from 'react';
import { MarkdownRender } from './MarkdownRender';

export type ITooltipMarkdownProps = {
description?: string;
value?: string;
};

export const TooltipMarkdown = memo(function TooltipMarkdown({ description, value }: ITooltipMarkdownProps) {
return (
<>
<div style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
<MarkdownRender value={value}>{description}</MarkdownRender>
</div>
<div className="opacity-0 overflow-hidden h-0" style={{ maxWidth: '80vw', whiteSpace: 'pre' }}>
<MarkdownRender value={value}>{description}</MarkdownRender>
</div>
</>
);
});
2 changes: 2 additions & 0 deletions statshouse-ui/src/components/Markdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './MarkdownRender';
export * from './TooltipMarkdown';
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,22 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

/* referring to tag <p> because <Markdown> returns content inside tag <p> */
.markdown {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}

.markdownMargin * {
.markdown * {
margin: 0;
}

.markdownSpace p,
.markdownSpace h1,
.markdownSpace h2,
.markdownSpace h3,
.markdownSpace h4,
.markdownSpace h5,
.markdownSpace h6,
.markdownSpace li {
white-space: break-spaces;
.markdownPreview {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
width: 0;
}

.markdownPreview *{
display: inline;
}
5 changes: 2 additions & 3 deletions statshouse-ui/src/components/UI/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import React from 'react';
import { Tooltip } from './Tooltip';
import { Tooltip, type TooltipProps } from './Tooltip';

export type ButtonProps = {
children?: React.ReactNode;
title?: React.ReactNode;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'title'>;
} & TooltipProps<'button'>;

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ children, title, ...props }: ButtonProps,
Expand Down
2 changes: 1 addition & 1 deletion statshouse-ui/src/components/UI/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const Tooltip = React.forwardRef<Element, TooltipProps<'div'>>(function T
className={cn(titleClassName, !noStyle && 'card overflow-auto')}
onClick={stopPropagation}
>
<div className={cn(!noStyle && 'card-body p-1')} style={{ minHeight, minWidth, maxHeight, maxWidth }}>
<div className={cn(!noStyle && 'card-body px-3 py-1')} style={{ minHeight, minWidth, maxHeight, maxWidth }}>
<TooltipTitleContent>{title}</TooltipTitleContent>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { MetricMetaTag } from '@/api/metric';
import { MetricTagValueInfo } from '@/api/metricTagValues';
import { escapeHTML } from '@/common/helpers';
import { Button } from '@/components/UI';
import { formatPercent } from '@/view/utils2';
import { clearOuterInfo, formatPercent, isOuterInfo } from '@/view/utils2';
import { TooltipMarkdown } from '@/components/Markdown/TooltipMarkdown';

const emptyListArray: MetricTagValueInfo[] = [];
const emptyValues: string[] = [];
Expand All @@ -39,7 +40,7 @@ export type VariableControlProps<T> = {
setOpen?: (name: T | undefined, value: boolean) => void;
customBadge?: React.ReactNode;
};
export function VariableControl<T>({
export function VariableControl<T extends string>({
target,
placeholder,
className,
Expand Down Expand Up @@ -126,7 +127,7 @@ export function VariableControl<T>({
<div className={cn('input-group flex-nowrap w-100', small ? 'input-group-sm' : 'input-group')}>
<TagSelect
values={negative ? notValues : values}
placeholder={placeholder}
placeholder={clearOuterInfo(placeholder)}
loading={loaded}
onChange={onChangeFilter}
moreOption={more}
Expand All @@ -147,24 +148,46 @@ export function VariableControl<T>({
{customBadge}
{values.map((v) => (
<Button
type="button"
key={v}
type="button"
data-value={v}
className="overflow-force-wrap btn btn-sm py-0 btn-success"
style={{ userSelect: 'text' }}
onClick={onRemoveFilter}
title={
isOuterInfo(placeholder) ? (
<div className="small text-secondary overflow-auto">
<TooltipMarkdown
description={placeholder}
value={formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
/>
</div>
) : undefined
}
hover
>
{formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
</Button>
))}
{notValues.map((v) => (
<Button
type="button"
key={v}
type="button"
data-value={v}
className="overflow-force-wrap btn btn-sm py-0 btn-danger"
style={{ userSelect: 'text' }}
onClick={onRemoveFilter}
title={
isOuterInfo(placeholder) ? (
<div className="small text-secondary overflow-auto">
<TooltipMarkdown
description={placeholder}
value={formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
/>
</div>
) : undefined
}
hover
>
{formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
</Button>
Expand Down
16 changes: 3 additions & 13 deletions statshouse-ui/src/components2/Dashboard/DashboardName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { memo, useMemo, useState } from 'react';
import { Tooltip } from '@/components/UI';
import { DashboardNameTitle } from './DashboardNameTitle';
import { useStatsHouseShallow } from '@/store2';
import css from '../style.module.css';
import { MarkdownRender } from '@/components2/Plot/PlotView/MarkdownRender';
import { MarkdownRender } from '@/components/Markdown/MarkdownRender';
import { produce } from 'immer';
import { StickyTop } from '../StickyTop';
import { SaveButton } from '../SaveButton';
Expand Down Expand Up @@ -80,17 +79,8 @@ export const DashboardName = memo(function DashboardName() {
{!!dashboardDescription && ':'}
</div>
{!!dashboardDescription && (
<div className="text-secondary flex-grow-1 w-0 overflow-hidden">
<MarkdownRender
className={css.markdown}
allowedElements={['p', 'a']}
components={{
p: ({ node, ...props }) => <span {...props} />,
}}
unwrapDisallowed
>
{dashboardDescription}
</MarkdownRender>
<div className="text-secondary flex-grow-1 w-0 d-flex overflow-hidden">
<MarkdownRender inline>{dashboardDescription}</MarkdownRender>
</div>
)}
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// 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 { TooltipMarkdown } from '@/components2/Plot/PlotView/TooltipMarkdown';
import { TooltipMarkdown } from '@/components/Markdown/TooltipMarkdown';

export type DashboardNameTitleProps = {
name: string;
Expand Down
Loading