add translation for tags
This commit is contained in:
@@ -67,6 +67,21 @@ export type HistoryResponse = {
|
||||
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. */
|
||||
async function request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||
const url = new URL(path, API_URL);
|
||||
@@ -133,3 +148,16 @@ export function getHistory(params: {
|
||||
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;
|
||||
from?: string;
|
||||
to?: string;
|
||||
tagLabels?: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Подбирает подпись оси времени под текущий масштаб графика. */
|
||||
@@ -46,7 +47,7 @@ function formatAxisDate(value: number, spanMs?: number): string {
|
||||
}
|
||||
|
||||
/** Рисует исторические ряды показателей через 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 xMax = to ? new Date(to).getTime() : 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:
|
||||
data?.series.map((series, index) => ({
|
||||
name: series.tag,
|
||||
name: tagLabels[series.tag] ?? series.tag,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
smooth: false,
|
||||
|
||||
@@ -5,28 +5,33 @@ import { getMetricStatus, type MetricStatusInfo } from '../utils/metricStatus';
|
||||
|
||||
type MetricCardProps = {
|
||||
item: CurrentItem;
|
||||
displayName?: string;
|
||||
selected: boolean;
|
||||
statusInfo?: MetricStatusInfo;
|
||||
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 ageLabel = ageSeconds < 60 ? `${ageSeconds}с` : `${Math.floor(ageSeconds / 60)}м ${ageSeconds % 60}с`;
|
||||
const title = displayName ?? item.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`metric-card metric-card--${status} ${selected ? 'metric-card--selected' : ''}`}
|
||||
type="button"
|
||||
onClick={() => onToggle(item.tag)}
|
||||
title={selected ? 'Убрать с графика' : 'Добавить на график'}
|
||||
title={`${selected ? 'Убрать с графика' : 'Добавить на график'}: ${title}`}
|
||||
>
|
||||
<div className="metric-card__header">
|
||||
<span className="metric-card__signal">
|
||||
<Activity size={15} />
|
||||
</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__state is-${status}`}>{label}</span>
|
||||
<span className="metric-card__age" title="Возраст последнего измерения">
|
||||
|
||||
@@ -16,7 +16,15 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
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 { HistoryChart } from '../components/HistoryChart';
|
||||
import { MetricCard } from '../components/MetricCard';
|
||||
@@ -73,6 +81,13 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
||||
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(() => {
|
||||
if (!edgeId || typeof EventSource === 'undefined') {
|
||||
setCurrentEventsConnected(false);
|
||||
@@ -103,11 +118,20 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
||||
};
|
||||
}, [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 query = search.trim().toLowerCase();
|
||||
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 latest = (current.data?.items ?? []).reduce<number | null>((max, item) => {
|
||||
@@ -272,6 +296,8 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
||||
onSelectVisibleTags={selectVisibleTags}
|
||||
onClearVisibleTags={clearVisibleTags}
|
||||
onClearTags={() => setSelectedTags([])}
|
||||
getTagLabel={getTagLabel}
|
||||
tagLabels={tagLabels}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -282,6 +308,7 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
|
||||
isError={current.isError}
|
||||
error={current.error}
|
||||
selectedTags={selectedTags}
|
||||
getTagLabel={getTagLabel}
|
||||
onSearchChange={setSearch}
|
||||
onToggleTag={toggleTag}
|
||||
/>
|
||||
@@ -381,6 +408,8 @@ function ArchiveView({
|
||||
onSelectVisibleTags,
|
||||
onClearVisibleTags,
|
||||
onClearTags,
|
||||
getTagLabel,
|
||||
tagLabels,
|
||||
}: {
|
||||
items: Array<{ tag: string; value: number; time: string }>;
|
||||
search: string;
|
||||
@@ -399,6 +428,8 @@ function ArchiveView({
|
||||
onSelectVisibleTags: () => void;
|
||||
onClearVisibleTags: () => void;
|
||||
onClearTags: () => void;
|
||||
getTagLabel: (tag: string) => string;
|
||||
tagLabels: Record<string, string>;
|
||||
}) {
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const selectedPreview = selectedTags.slice(0, 10);
|
||||
@@ -459,7 +490,7 @@ function ArchiveView({
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
|
||||
@@ -471,9 +502,13 @@ function ArchiveView({
|
||||
key={item.tag}
|
||||
type="button"
|
||||
className={`tag-select-item ${selected ? 'tag-select-item--selected' : ''}`}
|
||||
title={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>
|
||||
</button>
|
||||
);
|
||||
@@ -525,6 +560,7 @@ function ArchiveView({
|
||||
loading={historyLoading}
|
||||
from={toIsoFromInput(range.from)}
|
||||
to={toIsoFromInput(range.to)}
|
||||
tagLabels={tagLabels}
|
||||
/>
|
||||
) : (
|
||||
<div className="chart-placeholder">Разверните список показателей и выберите серии для графика</div>
|
||||
@@ -566,6 +602,7 @@ function IndicatorsView({
|
||||
isError,
|
||||
error,
|
||||
selectedTags,
|
||||
getTagLabel,
|
||||
onSearchChange,
|
||||
onToggleTag,
|
||||
}: {
|
||||
@@ -574,6 +611,7 @@ function IndicatorsView({
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
selectedTags: string[];
|
||||
getTagLabel: (tag: string) => string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onToggleTag: (tag: string) => void;
|
||||
}) {
|
||||
@@ -656,11 +694,11 @@ function IndicatorsView({
|
||||
key={`${item.edge}:${item.tag}`}
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
@@ -671,6 +709,7 @@ function IndicatorsView({
|
||||
<MetricCard
|
||||
key={`${item.edge}:${item.tag}`}
|
||||
item={item}
|
||||
displayName={getTagLabel(item.tag)}
|
||||
statusInfo={statusInfo}
|
||||
selected={selectedTags.includes(item.tag)}
|
||||
onToggle={onToggleTag}
|
||||
|
||||
@@ -424,15 +424,29 @@
|
||||
|
||||
.metric-card__tag {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.metric-card__tag span,
|
||||
.metric-card__tag small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 8px;
|
||||
|
||||
@@ -343,13 +343,29 @@
|
||||
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;
|
||||
font-size: 0.78rem;
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
color: #e5e7eb;
|
||||
font-size: 0.76rem;
|
||||
|
||||
Reference in New Issue
Block a user