add translation for tags
This commit is contained in:
@@ -67,6 +67,21 @@ export type HistoryResponse = {
|
|||||||
valueMode?: 'raw' | 'avg' | 'last' | 'first' | 'min' | 'max';
|
valueMode?: 'raw' | 'avg' | 'last' | 'first' | 'min' | 'max';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TagTranslationItem = {
|
||||||
|
edge: string;
|
||||||
|
tag: string;
|
||||||
|
locale: string;
|
||||||
|
displayName: string;
|
||||||
|
source: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TagTranslationResponse = {
|
||||||
|
edge: string;
|
||||||
|
locale: string;
|
||||||
|
items: TagTranslationItem[];
|
||||||
|
};
|
||||||
|
|
||||||
/** Выполняет типизированный GET-запрос к cloud-v2 и централизованно обрабатывает ошибки HTTP. */
|
/** Выполняет типизированный GET-запрос к cloud-v2 и централизованно обрабатывает ошибки HTTP. */
|
||||||
async function request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
async function request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||||
const url = new URL(path, API_URL);
|
const url = new URL(path, API_URL);
|
||||||
@@ -133,3 +148,16 @@ export function getHistory(params: {
|
|||||||
valueMode: params.valueMode,
|
valueMode: params.valueMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Загружает русскоязычный справочник названий тегов для выбранного edge. */
|
||||||
|
export function getTagTranslations(params: {
|
||||||
|
edge: string;
|
||||||
|
locale?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}): Promise<TagTranslationResponse> {
|
||||||
|
return request<TagTranslationResponse>('/tag-translations', {
|
||||||
|
edge: params.edge,
|
||||||
|
locale: params.locale ?? 'ru',
|
||||||
|
tags: params.tags?.join(','),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type HistoryChartProps = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
tagLabels?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Подбирает подпись оси времени под текущий масштаб графика. */
|
/** Подбирает подпись оси времени под текущий масштаб графика. */
|
||||||
@@ -46,7 +47,7 @@ function formatAxisDate(value: number, spanMs?: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Рисует исторические ряды показателей через ECharts с динамической временной шкалой. */
|
/** Рисует исторические ряды показателей через ECharts с динамической временной шкалой. */
|
||||||
export function HistoryChart({ data, loading, from, to }: HistoryChartProps) {
|
export function HistoryChart({ data, loading, from, to, tagLabels = {} }: HistoryChartProps) {
|
||||||
const xMin = from ? new Date(from).getTime() : undefined;
|
const xMin = from ? new Date(from).getTime() : undefined;
|
||||||
const xMax = to ? new Date(to).getTime() : undefined;
|
const xMax = to ? new Date(to).getTime() : undefined;
|
||||||
const xSpan = Number.isFinite(xMin) && Number.isFinite(xMax) ? Number(xMax) - Number(xMin) : undefined;
|
const xSpan = Number.isFinite(xMin) && Number.isFinite(xMax) ? Number(xMax) - Number(xMin) : undefined;
|
||||||
@@ -115,7 +116,7 @@ export function HistoryChart({ data, loading, from, to }: HistoryChartProps) {
|
|||||||
],
|
],
|
||||||
series:
|
series:
|
||||||
data?.series.map((series, index) => ({
|
data?.series.map((series, index) => ({
|
||||||
name: series.tag,
|
name: tagLabels[series.tag] ?? series.tag,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
smooth: false,
|
smooth: false,
|
||||||
|
|||||||
@@ -5,28 +5,33 @@ import { getMetricStatus, type MetricStatusInfo } from '../utils/metricStatus';
|
|||||||
|
|
||||||
type MetricCardProps = {
|
type MetricCardProps = {
|
||||||
item: CurrentItem;
|
item: CurrentItem;
|
||||||
|
displayName?: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
statusInfo?: MetricStatusInfo;
|
statusInfo?: MetricStatusInfo;
|
||||||
onToggle: (tag: string) => void;
|
onToggle: (tag: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Показывает детальную карточку текущего показателя и управляет добавлением тега на график. */
|
/** Показывает детальную карточку текущего показателя и управляет добавлением тега на график. */
|
||||||
export function MetricCard({ item, selected, statusInfo = getMetricStatus(item), onToggle }: MetricCardProps) {
|
export function MetricCard({ item, displayName, selected, statusInfo = getMetricStatus(item), onToggle }: MetricCardProps) {
|
||||||
const { ageSeconds, label, status } = statusInfo;
|
const { ageSeconds, label, status } = statusInfo;
|
||||||
const ageLabel = ageSeconds < 60 ? `${ageSeconds}с` : `${Math.floor(ageSeconds / 60)}м ${ageSeconds % 60}с`;
|
const ageLabel = ageSeconds < 60 ? `${ageSeconds}с` : `${Math.floor(ageSeconds / 60)}м ${ageSeconds % 60}с`;
|
||||||
|
const title = displayName ?? item.tag;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`metric-card metric-card--${status} ${selected ? 'metric-card--selected' : ''}`}
|
className={`metric-card metric-card--${status} ${selected ? 'metric-card--selected' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(item.tag)}
|
onClick={() => onToggle(item.tag)}
|
||||||
title={selected ? 'Убрать с графика' : 'Добавить на график'}
|
title={`${selected ? 'Убрать с графика' : 'Добавить на график'}: ${title}`}
|
||||||
>
|
>
|
||||||
<div className="metric-card__header">
|
<div className="metric-card__header">
|
||||||
<span className="metric-card__signal">
|
<span className="metric-card__signal">
|
||||||
<Activity size={15} />
|
<Activity size={15} />
|
||||||
</span>
|
</span>
|
||||||
<span className="metric-card__tag">{item.tag}</span>
|
<span className="metric-card__tag">
|
||||||
|
<span>{title}</span>
|
||||||
|
{displayName && displayName !== item.tag ? <small>{item.tag}</small> : null}
|
||||||
|
</span>
|
||||||
<span className="metric-card__corner">
|
<span className="metric-card__corner">
|
||||||
<span className={`metric-card__state is-${status}`}>{label}</span>
|
<span className={`metric-card__state is-${status}`}>{label}</span>
|
||||||
<span className="metric-card__age" title="Возраст последнего измерения">
|
<span className="metric-card__age" title="Возраст последнего измерения">
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { CurrentEvent, CurrentItem, CurrentResponse, getCurrent, getCurrentEventsUrl, getHistory } from '../api/cloud';
|
import {
|
||||||
|
CurrentEvent,
|
||||||
|
CurrentItem,
|
||||||
|
CurrentResponse,
|
||||||
|
getCurrent,
|
||||||
|
getCurrentEventsUrl,
|
||||||
|
getHistory,
|
||||||
|
getTagTranslations,
|
||||||
|
} from '../api/cloud';
|
||||||
import { useAuth } from '../auth/authContext';
|
import { useAuth } from '../auth/authContext';
|
||||||
import { HistoryChart } from '../components/HistoryChart';
|
import { HistoryChart } from '../components/HistoryChart';
|
||||||
import { MetricCard } from '../components/MetricCard';
|
import { MetricCard } from '../components/MetricCard';
|
||||||
@@ -73,6 +81,13 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
|||||||
refetchInterval: currentEventsConnected ? false : 1_000,
|
refetchInterval: currentEventsConnected ? false : 1_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagTranslations = useQuery({
|
||||||
|
queryKey: ['tag-translations', edgeId, 'ru'],
|
||||||
|
queryFn: () => getTagTranslations({ edge: edgeId, locale: 'ru' }),
|
||||||
|
enabled: Boolean(edgeId),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!edgeId || typeof EventSource === 'undefined') {
|
if (!edgeId || typeof EventSource === 'undefined') {
|
||||||
setCurrentEventsConnected(false);
|
setCurrentEventsConnected(false);
|
||||||
@@ -103,11 +118,20 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
|||||||
};
|
};
|
||||||
}, [edgeId, queryClient]);
|
}, [edgeId, queryClient]);
|
||||||
|
|
||||||
|
const tagLabels = useMemo(() => {
|
||||||
|
return Object.fromEntries((tagTranslations.data?.items ?? []).map((item) => [item.tag, item.displayName]));
|
||||||
|
}, [tagTranslations.data?.items]);
|
||||||
|
|
||||||
|
const getTagLabel = (tag: string) => tagLabels[tag] ?? tag;
|
||||||
|
|
||||||
const visibleItems = useMemo(() => {
|
const visibleItems = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
const items = current.data?.items ?? [];
|
const items = current.data?.items ?? [];
|
||||||
return query ? items.filter((item) => item.tag.toLowerCase().includes(query)) : items;
|
|
||||||
}, [current.data?.items, search]);
|
return query
|
||||||
|
? items.filter((item) => `${item.tag} ${tagLabels[item.tag] ?? ''}`.toLowerCase().includes(query))
|
||||||
|
: items;
|
||||||
|
}, [current.data?.items, search, tagLabels]);
|
||||||
|
|
||||||
const latestUpdatedAt = useMemo(() => {
|
const latestUpdatedAt = useMemo(() => {
|
||||||
const latest = (current.data?.items ?? []).reduce<number | null>((max, item) => {
|
const latest = (current.data?.items ?? []).reduce<number | null>((max, item) => {
|
||||||
@@ -272,6 +296,8 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
|||||||
onSelectVisibleTags={selectVisibleTags}
|
onSelectVisibleTags={selectVisibleTags}
|
||||||
onClearVisibleTags={clearVisibleTags}
|
onClearVisibleTags={clearVisibleTags}
|
||||||
onClearTags={() => setSelectedTags([])}
|
onClearTags={() => setSelectedTags([])}
|
||||||
|
getTagLabel={getTagLabel}
|
||||||
|
tagLabels={tagLabels}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -282,6 +308,7 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
|||||||
isError={current.isError}
|
isError={current.isError}
|
||||||
error={current.error}
|
error={current.error}
|
||||||
selectedTags={selectedTags}
|
selectedTags={selectedTags}
|
||||||
|
getTagLabel={getTagLabel}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
onToggleTag={toggleTag}
|
onToggleTag={toggleTag}
|
||||||
/>
|
/>
|
||||||
@@ -381,6 +408,8 @@ function ArchiveView({
|
|||||||
onSelectVisibleTags,
|
onSelectVisibleTags,
|
||||||
onClearVisibleTags,
|
onClearVisibleTags,
|
||||||
onClearTags,
|
onClearTags,
|
||||||
|
getTagLabel,
|
||||||
|
tagLabels,
|
||||||
}: {
|
}: {
|
||||||
items: Array<{ tag: string; value: number; time: string }>;
|
items: Array<{ tag: string; value: number; time: string }>;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -399,6 +428,8 @@ function ArchiveView({
|
|||||||
onSelectVisibleTags: () => void;
|
onSelectVisibleTags: () => void;
|
||||||
onClearVisibleTags: () => void;
|
onClearVisibleTags: () => void;
|
||||||
onClearTags: () => void;
|
onClearTags: () => void;
|
||||||
|
getTagLabel: (tag: string) => string;
|
||||||
|
tagLabels: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||||
const selectedPreview = selectedTags.slice(0, 10);
|
const selectedPreview = selectedTags.slice(0, 10);
|
||||||
@@ -459,7 +490,7 @@ function ArchiveView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="selected-tags">
|
<div className="selected-tags">
|
||||||
{selectedPreview.length ? selectedPreview.map((tag) => <span key={tag}>{tag}</span>) : <span>Не выбрано</span>}
|
{selectedPreview.length ? selectedPreview.map((tag) => <span key={tag}>{getTagLabel(tag)}</span>) : <span>Не выбрано</span>}
|
||||||
{hiddenSelectedCount > 0 ? <span>+{hiddenSelectedCount}</span> : null}
|
{hiddenSelectedCount > 0 ? <span>+{hiddenSelectedCount}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -471,9 +502,13 @@ function ArchiveView({
|
|||||||
key={item.tag}
|
key={item.tag}
|
||||||
type="button"
|
type="button"
|
||||||
className={`tag-select-item ${selected ? 'tag-select-item--selected' : ''}`}
|
className={`tag-select-item ${selected ? 'tag-select-item--selected' : ''}`}
|
||||||
|
title={item.tag}
|
||||||
onClick={() => onToggleTag(item.tag)}
|
onClick={() => onToggleTag(item.tag)}
|
||||||
>
|
>
|
||||||
<span>{item.tag}</span>
|
<span className="tag-select-item__name">
|
||||||
|
<span>{getTagLabel(item.tag)}</span>
|
||||||
|
{getTagLabel(item.tag) !== item.tag ? <small>{item.tag}</small> : null}
|
||||||
|
</span>
|
||||||
<strong>{item.value.toLocaleString('ru-RU', { maximumFractionDigits: 3 })}</strong>
|
<strong>{item.value.toLocaleString('ru-RU', { maximumFractionDigits: 3 })}</strong>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -525,6 +560,7 @@ function ArchiveView({
|
|||||||
loading={historyLoading}
|
loading={historyLoading}
|
||||||
from={toIsoFromInput(range.from)}
|
from={toIsoFromInput(range.from)}
|
||||||
to={toIsoFromInput(range.to)}
|
to={toIsoFromInput(range.to)}
|
||||||
|
tagLabels={tagLabels}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="chart-placeholder">Разверните список показателей и выберите серии для графика</div>
|
<div className="chart-placeholder">Разверните список показателей и выберите серии для графика</div>
|
||||||
@@ -566,6 +602,7 @@ function IndicatorsView({
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
|
getTagLabel,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
}: {
|
}: {
|
||||||
@@ -574,6 +611,7 @@ function IndicatorsView({
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
|
getTagLabel: (tag: string) => string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onToggleTag: (tag: string) => void;
|
onToggleTag: (tag: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -656,11 +694,11 @@ function IndicatorsView({
|
|||||||
key={`${item.edge}:${item.tag}`}
|
key={`${item.edge}:${item.tag}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={`metric-tile metric-tile--${statusInfo.status} ${selectedTags.includes(item.tag) ? 'metric-tile--selected' : ''}`}
|
className={`metric-tile metric-tile--${statusInfo.status} ${selectedTags.includes(item.tag) ? 'metric-tile--selected' : ''}`}
|
||||||
title={`${item.tag}: ${formatNumber(item.value)} · ${statusInfo.label}`}
|
title={`${getTagLabel(item.tag)} (${item.tag}): ${formatNumber(item.value)} · ${statusInfo.label}`}
|
||||||
onClick={() => onToggleTag(item.tag)}
|
onClick={() => onToggleTag(item.tag)}
|
||||||
>
|
>
|
||||||
<span className="metric-tile__status" />
|
<span className="metric-tile__status" />
|
||||||
<span className="metric-tile__tag">{item.tag}</span>
|
<span className="metric-tile__tag">{getTagLabel(item.tag)}</span>
|
||||||
<strong>{formatNumber(item.value)}</strong>
|
<strong>{formatNumber(item.value)}</strong>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -671,6 +709,7 @@ function IndicatorsView({
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
key={`${item.edge}:${item.tag}`}
|
key={`${item.edge}:${item.tag}`}
|
||||||
item={item}
|
item={item}
|
||||||
|
displayName={getTagLabel(item.tag)}
|
||||||
statusInfo={statusInfo}
|
statusInfo={statusInfo}
|
||||||
selected={selectedTags.includes(item.tag)}
|
selected={selectedTags.includes(item.tag)}
|
||||||
onToggle={onToggleTag}
|
onToggle={onToggleTag}
|
||||||
|
|||||||
@@ -424,15 +424,29 @@
|
|||||||
|
|
||||||
.metric-card__tag {
|
.metric-card__tag {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__tag span,
|
||||||
|
.metric-card__tag small {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-card__tag small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card__state {
|
.metric-card__state {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
|||||||
@@ -343,13 +343,29 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-select-item span {
|
.tag-select-item__name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-select-item__name span,
|
||||||
|
.tag-select-item__name small {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.78rem;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-select-item__name span {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-select-item__name small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-select-item strong {
|
.tag-select-item strong {
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user