From 26ef325ac1f57f001df11318cfccd94c0cdd2a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Fri, 19 Jun 2026 08:22:14 +0300 Subject: [PATCH] change after review --- .env.example | 3 +- .env.local.example | 5 +- Dockerfile | 2 +- docker-compose.yml | 4 +- src/App.tsx | 2 + src/api/cloud.ts | 90 +++-- src/api/diagram.ts | 124 +++++++ src/components/HistoryChart.tsx | 188 ++++++++-- src/pages/EdgeDetailPage.tsx | 140 ++++---- src/pages/EdgesDashboard.tsx | 52 ++- src/pages/ElectricalSchematicsPage.tsx | 476 +++++++++++++++++++++++++ src/styles/charts.css | 32 ++ src/styles/detail.css | 302 ++++++++++++++++ src/styles/layout.css | 3 +- src/utils/format.ts | 7 +- 15 files changed, 1265 insertions(+), 165 deletions(-) create mode 100644 src/api/diagram.ts create mode 100644 src/pages/ElectricalSchematicsPage.tsx diff --git a/.env.example b/.env.example index d1afa6d..28cc5bb 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ -VITE_CLOUD_API_URL=http://localhost:3100 +VITE_CLOUD_API_URL=http://localhost:3101 +VITE_DIAGRAM_API_URL=http://localhost:3002 VITE_DEFAULT_EDGE=edge5 diff --git a/.env.local.example b/.env.local.example index 004f8e6..f143beb 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,9 +1,10 @@ -VITE_CLOUD_API_URL=http://localhost:3100 +VITE_CLOUD_API_URL=http://localhost:3101 +VITE_DIAGRAM_API_URL=http://localhost:3002 # Если эти переменные не заданы, greact.drill.ui работает без SSO для локальной разработки. VITE_KEYCLOAK_URL=https://sso.greact.ru VITE_KEYCLOAK_REALM=toir VITE_KEYCLOAK_CLIENT_ID=drill-ui-frontend -# Origin встроенного TOиР light. По умолчанию используется https://toir-light.greact.ru. +# Origin встроенного ТОиР light. По умолчанию используется https://toir-light.greact.ru. VITE_TOIR_LIGHT_ORIGIN=https://toir-light.greact.ru diff --git a/Dockerfile b/Dockerfile index 5ec076c..64dcbad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ FROM node:22-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -ARG VITE_CLOUD_API_URL=http://localhost:3100 +ARG VITE_CLOUD_API_URL=http://localhost:3101 ARG VITE_DEFAULT_EDGE=edge5 ENV VITE_CLOUD_API_URL=$VITE_CLOUD_API_URL ENV VITE_DEFAULT_EDGE=$VITE_DEFAULT_EDGE diff --git a/docker-compose.yml b/docker-compose.yml index f232542..9a1e3ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,11 @@ services: build: context: . args: - VITE_CLOUD_API_URL: ${VITE_CLOUD_API_URL:-http://localhost:3100} + VITE_CLOUD_API_URL: ${VITE_CLOUD_API_URL:-http://localhost:3101} VITE_DEFAULT_EDGE: ${VITE_DEFAULT_EDGE:-edge5} expose: - "80" networks: external: - true \ No newline at end of file + true diff --git a/src/App.tsx b/src/App.tsx index bb6950a..6529772 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,8 @@ export default function App() { } /> } /> } /> + {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} + {/* } /> */} } /> diff --git a/src/api/cloud.ts b/src/api/cloud.ts index c4ab647..bf2c42b 100644 --- a/src/api/cloud.ts +++ b/src/api/cloud.ts @@ -1,4 +1,4 @@ -const API_URL = import.meta.env.VITE_CLOUD_API_URL ?? 'http://localhost:3100'; +const API_URL = import.meta.env.VITE_CLOUD_API_URL ?? 'http://localhost:3101'; export type HealthResponse = { status: 'ok'; @@ -13,8 +13,16 @@ export type CurrentItem = { edge: string; tag: string; value: number; - time: string; + createdAt: string; updatedAt: string; + time: string; + name: string | null; + tagGroup: string | null; + min: number | null; + max: number | null; + comment: string | null; + unitOfMeasurement: string | null; + precision: number | null; }; export type CurrentResponse = { @@ -27,27 +35,41 @@ export type CurrentEvent = CurrentResponse; export type EdgeItem = { id: string; name: string; + parentId: string | null; + tagIds: string[]; tagCount: number; currentTagCount: number; liveTagCount: number; lastDataAt: string | null; - createdAt: string; - updatedAt: string; }; export type EdgeResponse = { items: EdgeItem[]; }; +export type TagItem = { + id: string; + name: string; + tagGroup: string | null; + min: number; + max: number; + comment: string; + unitOfMeasurement: string; + edgeIds: string[]; + precision: number | null; +}; + +export type TagResponse = { + items: TagItem[]; +}; + export type HistoryPoint = { t: number; v: number; - first?: number; - min?: number; - max?: number; - avg?: number; - last?: number; - count?: number; + min: number; + avg: number; + max: number; + count: number; }; export type HistorySeries = { @@ -64,25 +86,9 @@ export type HistoryResponse = { from?: string; to?: string; resolutionSeconds?: number | null; - 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-v3 и централизованно обрабатывает HTTP-ошибки. */ async function request(path: string, params?: Record): Promise { const url = new URL(path, API_URL); Object.entries(params ?? {}).forEach(([key, value]) => { @@ -100,17 +106,22 @@ async function request(path: string, params?: Record; } -/** Запрашивает состояние cloud-v2 и подключенной базы данных. */ +/** Запрашивает состояние cloud-v3 и подключенной базы данных. */ export function getHealth(): Promise { return request('/health'); } -/** Возвращает список edge-установок, доступных в cloud-v2. */ +/** Возвращает список edge-установок, доступных в cloud-v3. */ export function getEdges(): Promise { return request('/edge'); } -/** Загружает текущие значения показателей для выбранного edge. */ +/** Загружает справочник тегов, используя tag.name как отображаемое имя. */ +export function getTags(params: { edge?: string; search?: string } = {}): Promise { + return request('/tag', params); +} + +/** Загружает текущие значения показателей для выбранного edge вместе с метаданными тегов. */ export function getCurrent(edge: string, tags?: string[]): Promise { return request('/current', { edge, @@ -118,7 +129,7 @@ export function getCurrent(edge: string, tags?: string[]): Promise { return request('/history', { edge: params.edge, @@ -145,19 +155,5 @@ export function getHistory(params: { from: params.from, to: params.to, targetPoints: params.targetPoints, - valueMode: params.valueMode, - }); -} - -/** Загружает русскоязычный справочник названий тегов для выбранного edge. */ -export function getTagTranslations(params: { - edge: string; - locale?: string; - tags?: string[]; -}): Promise { - return request('/tag-translations', { - edge: params.edge, - locale: params.locale ?? 'ru', - tags: params.tags?.join(','), }); } diff --git a/src/api/diagram.ts b/src/api/diagram.ts new file mode 100644 index 0000000..67e9260 --- /dev/null +++ b/src/api/diagram.ts @@ -0,0 +1,124 @@ +import { getAccessToken } from '../auth/keycloak'; + +const DIAGRAM_API_URL = import.meta.env.VITE_DIAGRAM_API_URL ?? 'http://localhost:3002'; + +export type DiagramPoint = { + x: number; + y: number; +}; + +export type DiagramSize = { + width: number; + height: number; +}; + +export type DiagramBindingConfig = { + stateTagId?: string; + alarmTagId?: string; + edgeId?: string; + sseEdgeId?: string; + sseUrl?: string; +}; + +export type DiagramNode = + | { + id: string; + kind: 'tagWidget'; + tagId: string; + edgeId: string; + widgetType: string; + position: DiagramPoint; + size?: DiagramSize; + zIndex?: number; + label?: string; + displayType?: 'widget' | 'compact' | 'card'; + style?: Record; + } + | { + id: string; + kind: 'decoration'; + decorationType: string; + position: DiagramPoint; + size: DiagramSize; + rotation?: number; + zIndex?: number; + data?: Record; + style?: Record; + bindings?: DiagramBindingConfig; + }; + +export type DiagramEdge = { + id: string; + sourceNodeId: string; + targetNodeId: string; + sourceSide?: 'left' | 'right' | 'top' | 'bottom'; + targetSide?: 'left' | 'right' | 'top' | 'bottom'; + kind: 'wire' | 'power' | 'signal' | 'alert'; + label?: string; + animated?: boolean; + waypoints?: DiagramPoint[]; +}; + +export type DiagramDocument = { + schemaVersion: number; + pageKey: string; + ownerEdgeId: string; + title?: string; + viewport?: { x: number; y: number; zoom: number } | null; + background?: { + url?: string; + opacity?: number; + fit?: 'contain' | 'cover' | 'stretch'; + }; + nodes: DiagramNode[]; + edges: DiagramEdge[]; +}; + +export type DiagramPageSummary = { + pageKey: string; + ownerEdgeId: string; + title?: string | null; + schemaVersion: number; + revision: number; + status: 'draft' | 'published' | 'archived'; + publishedRevision?: number; + publishedAt?: string; + createdAt: string; + updatedAt: string; +}; + +export type DiagramPageResponse = DiagramPageSummary & { + document: DiagramDocument; +}; + +/** Выполняет запрос к diagram-service и пробрасывает Bearer-токен, если UI работает с SSO. */ +async function request(path: string, params?: Record): Promise { + const url = new URL(path, DIAGRAM_API_URL); + Object.entries(params ?? {}).forEach(([key, value]) => { + if (value) { + url.searchParams.set(key, value); + } + }); + + const token = await getAccessToken(); + const response = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `${response.status} ${response.statusText}`); + } + + return response.json() as Promise; +} + +/** Возвращает список страниц схем, опубликованность определяется по publishedRevision. */ +export function listDiagramPages(ownerEdgeId?: string): Promise { + return request('/diagram/pages', { ownerEdgeId }); +} + +/** Загружает опубликованную версию страницы схемы. */ +export function getPublishedDiagramPage(pageKey: string): Promise { + return request(`/diagram/pages/${encodeURIComponent(pageKey)}/published`); +} diff --git a/src/components/HistoryChart.tsx b/src/components/HistoryChart.tsx index 2571709..13ad13a 100644 --- a/src/components/HistoryChart.tsx +++ b/src/components/HistoryChart.tsx @@ -1,4 +1,4 @@ -import { LineChart } from 'echarts/charts'; +import { LineChart, ScatterChart } from 'echarts/charts'; import { DataZoomComponent, GridComponent, @@ -8,9 +8,18 @@ import { import * as echarts from 'echarts/core'; import { CanvasRenderer } from 'echarts/renderers'; import ReactEChartsCore from 'echarts-for-react/esm/core.js'; +import type { EChartsOption, SeriesOption } from 'echarts'; import type { HistoryResponse } from '../api/cloud'; -echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]); +echarts.use([ + LineChart, + ScatterChart, + GridComponent, + LegendComponent, + TooltipComponent, + DataZoomComponent, + CanvasRenderer, +]); const SERIES_COLORS = [ '#5B8FF9', @@ -31,6 +40,14 @@ type HistoryChartProps = { tagLabels?: Record; }; +type AvgPointValue = [time: number, value: number, min: number, avg: number, max: number, count: number]; + +type TooltipParam = { + marker?: string; + seriesName?: string; + value?: unknown; +}; + /** Подбирает подпись оси времени под текущий масштаб графика. */ function formatAxisDate(value: number, spanMs?: number): string { const date = new Date(value); @@ -46,13 +63,155 @@ function formatAxisDate(value: number, spanMs?: number): string { return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date); } -/** Рисует исторические ряды показателей через ECharts с динамической временной шкалой. */ +/** Форматирует числовое значение для tooltip без лишнего шума в дробной части. */ +function formatChartValue(value: number): string { + return new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: Math.abs(value) >= 100 ? 2 : 3, + }).format(value); +} + +/** Проверяет, что tooltip получил основную точку avg-линии, а не вспомогательную серию. */ +function isAvgPointValue(value: unknown): value is AvgPointValue { + return Array.isArray(value) && value.length >= 6 && value.every((item) => typeof item === 'number'); +} + +/** Собирает tooltip с min/avg/max для всех серий на выбранной отметке времени. */ +function formatTooltip(params: unknown): string { + const items = (Array.isArray(params) ? params : [params]).filter( + (item): item is TooltipParam => typeof item === 'object' && item !== null, + ); + const avgItems = items.filter((item) => isAvgPointValue(item.value)); + const firstValue = avgItems[0]?.value as AvgPointValue | undefined; + + if (!firstValue) { + return ''; + } + + const header = new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(firstValue[0])); + + const rows = avgItems.map((item) => { + const value = item.value as AvgPointValue; + return [ + '
', + `${item.marker ?? ''}${item.seriesName ?? ''}`, + '', + `min ${formatChartValue(value[2])} · avg ${formatChartValue(value[3])} · max ${formatChartValue(value[4])}`, + value[5] > 1 ? ` · ${value[5]} точек` : '', + '', + '
', + ].join(''); + }); + + return [`
${header}
`, ...rows, '
'].join(''); +} + +/** Создает avg-линию, вертикальные min/max отрезки и точки экстремумов для одной серии. */ +function createSeriesOptions( + series: NonNullable[number], + index: number, + label: string, +): SeriesOption[] { + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + const avgData: AvgPointValue[] = series.points.map((point) => [ + point.t, + point.v, + point.min, + point.avg, + point.max, + point.count, + ]); + const spreadData = series.points.flatMap((point) => [ + [point.t, point.min], + [point.t, point.max], + [point.t, null], + ]); + + return [ + { + name: `${label} min/max`, + type: 'line', + data: spreadData, + showSymbol: false, + connectNulls: false, + silent: true, + tooltip: { show: false }, + legendHoverLink: false, + lineStyle: { + width: 1, + opacity: 0.42, + color, + }, + emphasis: { + disabled: true, + }, + z: 1, + }, + { + name: `${label} min`, + type: 'scatter', + data: series.points.map((point) => [point.t, point.min]), + symbolSize: 4, + silent: true, + tooltip: { show: false }, + itemStyle: { + color, + opacity: 0.72, + }, + emphasis: { + disabled: true, + }, + z: 2, + }, + { + name: `${label} max`, + type: 'scatter', + data: series.points.map((point) => [point.t, point.max]), + symbolSize: 4, + silent: true, + tooltip: { show: false }, + itemStyle: { + color, + opacity: 0.72, + }, + emphasis: { + disabled: true, + }, + z: 2, + }, + { + name: label, + type: 'line', + showSymbol: false, + smooth: false, + sampling: 'lttb', + data: avgData, + lineStyle: { + width: 1.8, + color, + }, + emphasis: { + focus: 'series', + }, + z: 3, + }, + ]; +} + +/** Рисует исторические ряды: avg соединяется линией, min/max показываются точками и вертикальным диапазоном. */ 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; + const legendData = data?.series.map((series) => tagLabels[series.tag] ?? series.tag) ?? []; - const option = { + const option: EChartsOption = { color: SERIES_COLORS, animation: false, backgroundColor: 'transparent', @@ -65,11 +224,12 @@ export function HistoryChart({ data, loading, from, to, tagLabels = {} }: Histor backgroundColor: 'rgba(10, 13, 18, 0.96)', borderColor: 'rgba(212, 165, 116, 0.32)', textStyle: { color: '#f8fafc' }, - valueFormatter: (value: unknown) => (typeof value === 'number' ? value.toFixed(3) : String(value)), + formatter: formatTooltip, }, legend: { type: 'scroll', top: 0, + data: legendData, textStyle: { color: '#cbd5e1' }, }, grid: { @@ -115,21 +275,9 @@ export function HistoryChart({ data, loading, from, to, tagLabels = {} }: Histor }, ], series: - data?.series.map((series, index) => ({ - name: tagLabels[series.tag] ?? series.tag, - type: 'line', - showSymbol: false, - smooth: false, - sampling: 'lttb', - lineStyle: { - width: 1.8, - color: SERIES_COLORS[index % SERIES_COLORS.length], - }, - emphasis: { - focus: 'series', - }, - data: series.points.map((point) => [point.t, point.v]), - })) ?? [], + data?.series.flatMap((series, index) => + createSeriesOptions(series, index, tagLabels[series.tag] ?? series.tag), + ) ?? [], }; if (loading) { diff --git a/src/pages/EdgeDetailPage.tsx b/src/pages/EdgeDetailPage.tsx index e2625ac..bfd1178 100644 --- a/src/pages/EdgeDetailPage.tsx +++ b/src/pages/EdgeDetailPage.tsx @@ -16,15 +16,8 @@ import { Wrench, } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { - CurrentEvent, - CurrentItem, - CurrentResponse, - getCurrent, - getCurrentEventsUrl, - getHistory, - getTagTranslations, -} from '../api/cloud'; +import type { CurrentEvent, CurrentItem, CurrentResponse, HistoryResponse } from '../api/cloud'; +import { getCurrent, getCurrentEventsUrl, getHistory } from '../api/cloud'; import { useAuth } from '../auth/authContext'; import { HistoryChart } from '../components/HistoryChart'; import { MetricCard } from '../components/MetricCard'; @@ -49,8 +42,13 @@ type EdgeDetailPageProps = { view: DetailView; }; +type DateRangeState = { + from: string; + to: string; +}; + /** Создает диапазон истории от текущего времени на заданное количество часов назад. */ -function createRange(hours: number) { +function createRange(hours: number): DateRangeState { const to = new Date(); const from = new Date(to.getTime() - hours * 60 * 60 * 1000); return { @@ -59,7 +57,12 @@ function createRange(hours: number) { }; } -/** Собирает общий layout edge-раздела: меню, live-данные, архив, показатели и iframe оборудования. */ +/** Возвращает русское имя тега из tag.name, если оно пришло из cloud-v3. */ +function getItemLabel(item: CurrentItem): string { + return item.name?.trim() || item.tag; +} + +/** Собирает общий layout edge-раздела: меню, текущие данные, архив, показатели и iframe оборудования. */ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -70,7 +73,6 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { const [search, setSearch] = useState(''); const [range, setRange] = useState(() => createRange(24)); const [selectedTags, setSelectedTags] = useState([]); - const [valueMode, setValueMode] = useState<'avg' | 'last' | 'min' | 'max'>('avg'); const edgePath = `/edges/${encodeURIComponent(edgeId)}`; @@ -81,13 +83,6 @@ 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); @@ -100,16 +95,7 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { eventSource.onerror = () => setCurrentEventsConnected(false); eventSource.onmessage = (message) => { const event = JSON.parse(message.data) as CurrentEvent; - - queryClient.setQueryData(['current', edgeId], (previous) => { - const byTag = new Map((previous?.items ?? []).map((item) => [item.tag, item])); - event.items.forEach((item) => byTag.set(item.tag, item)); - - return { - edge: event.edge, - items: Array.from(byTag.values()).sort((left, right) => left.tag.localeCompare(right.tag)), - }; - }); + queryClient.setQueryData(['current', edgeId], event); }; return () => { @@ -119,8 +105,8 @@ 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]); + return Object.fromEntries((current.data?.items ?? []).map((item) => [item.tag, getItemLabel(item)])); + }, [current.data?.items]); const getTagLabel = (tag: string) => tagLabels[tag] ?? tag; @@ -129,9 +115,21 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { const items = current.data?.items ?? []; return query - ? items.filter((item) => `${item.tag} ${tagLabels[item.tag] ?? ''}`.toLowerCase().includes(query)) + ? items.filter((item) => + [ + item.tag, + item.name, + item.comment, + item.tagGroup, + item.unitOfMeasurement, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(query), + ) : items; - }, [current.data?.items, search, tagLabels]); + }, [current.data?.items, search]); const latestUpdatedAt = useMemo(() => { const latest = (current.data?.items ?? []).reduce((max, item) => { @@ -142,13 +140,14 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { return latest === null ? undefined : new Date(latest); }, [current.data?.items]); - const liveCount = current.data?.items.filter((item) => { - const ageSeconds = (Date.now() - new Date(item.time).getTime()) / 1000; - return ageSeconds <= 30; - }).length ?? 0; + const liveCount = + current.data?.items.filter((item) => { + const ageSeconds = (Date.now() - new Date(item.time).getTime()) / 1000; + return ageSeconds <= 30; + }).length ?? 0; const history = useQuery({ - queryKey: ['history', edgeId, selectedTags, range.from, range.to, valueMode], + queryKey: ['history', edgeId, selectedTags, range.from, range.to], queryFn: () => getHistory({ edge: edgeId, @@ -156,7 +155,6 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { from: toIsoFromInput(range.from), to: toIsoFromInput(range.to), targetPoints: 1600, - valueMode, }), enabled: Boolean(edgeId && selectedTags.length), refetchInterval: 5_000, @@ -238,6 +236,15 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { Оборудование + {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} + {/* */} @@ -246,6 +253,9 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) {
Операторская панель

Буровая установка {edgeId}

+ + {currentEventsConnected ? 'SSE live' : 'polling'} +
) : null}
@@ -285,12 +295,9 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { history={history.data} historyLoading={history.isPending && selectedTags.length > 0} historySource={history.data?.source} - resolutionSeconds={history.data?.resolutionSeconds} range={range} - valueMode={valueMode} onSearchChange={setSearch} onRangeChange={setRange} - onValueModeChange={setValueMode} onToggleTag={toggleTag} onSelectFirstTags={selectFirstTags} onSelectVisibleTags={selectVisibleTags} @@ -315,6 +322,9 @@ export function EdgeDetailPage({ view }: EdgeDetailPageProps) { ) : null} {view === 'equipment' ? : null} + + {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} + {/* {view === 'electrical' ? : null} */} ); @@ -357,7 +367,7 @@ function OverviewView({
Последнее обновление - {latestUpdatedAt ? formatDateTime(latestUpdatedAt) : '—'} + {latestUpdatedAt ? formatDateTime(latestUpdatedAt) : '-'}
@@ -379,6 +389,11 @@ function OverviewView({ Состояние оборудования + {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} + {/* */} ); })} @@ -543,15 +552,6 @@ function ArchiveView({ onChange={(event) => onRangeChange({ ...range, to: event.target.value })} /> - {selectedTags.length ? ( @@ -570,7 +570,7 @@ function ArchiveView({ ); } -/** Встраивает страницу активного оборудования из TOиР light почти на всю рабочую область. */ +/** Встраивает страницу активного оборудования из ТОиР light почти на всю рабочую область. */ function EquipmentView() { const [iframeLoaded, setIframeLoaded] = useState(false); diff --git a/src/pages/EdgesDashboard.tsx b/src/pages/EdgesDashboard.tsx index 9ba953a..89e2759 100644 --- a/src/pages/EdgesDashboard.tsx +++ b/src/pages/EdgesDashboard.tsx @@ -33,7 +33,7 @@ function getEdgeTitle(edge: EdgeItem): string { return edge.id; } -/** Отображает стартовый dashboard со списком edge-установок и общей статистикой. */ +/** Отображает стартовый dashboard со списком edge-установок и общей статистикой cloud-v3. */ export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardProps) { const [search, setSearch] = useState(''); const auth = useAuth(); @@ -54,19 +54,26 @@ export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardPr return items.filter((edge) => `${edge.id} ${edge.name}`.toLowerCase().includes(query)); }, [edges.data?.items, search]); - const stats = { - total: edges.data?.items.length ?? 0, - normal: 0, - emergency: 0, - equipmentProblem: 0, - maintenanceRequired: 0, - }; + const stats = useMemo(() => { + const items = edges.data?.items ?? []; + const normal = items.filter((edge) => getEdgeState(edge) === 'ok').length; + const stale = items.filter((edge) => getEdgeState(edge) === 'warn').length; + const empty = items.filter((edge) => getEdgeState(edge) === 'empty').length; + + return { + total: items.length, + normal, + stale, + empty, + liveTags: items.reduce((sum, edge) => sum + edge.liveTagCount, 0), + }; + }, [edges.data?.items]); return (
- Drill Cloud + Drill Cloud v3

Буровые установки

@@ -80,7 +87,7 @@ export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardPr {auth.enabled ? ( ) : null}
@@ -89,9 +96,9 @@ export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardPr
- - - + + +
{edges.isError ? ( @@ -99,13 +106,19 @@ export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardPr ) : (
{filteredEdges.map((edge, index) => ( - + ))}
)} {!edges.isPending && !filteredEdges.length && !edges.isError ? ( -
В cloud-v2 пока нет буровых
+
В cloud-v3 пока нет буровых
) : null}
); @@ -129,7 +142,7 @@ function StatBlock({ ); } -/** Рендерит продуктовую карточку edge с быстрыми переходами в рабочие разделы. */ +/** Рендерит карточку edge с быстрыми переходами в рабочие разделы. */ function EdgeCard({ edge, onOpenEdge, @@ -174,7 +187,7 @@ function EdgeCard({ Последние данные -
{edge.lastDataAt ? formatDateTime(edge.lastDataAt) : '—'}
+
{edge.lastDataAt ? formatDateTime(edge.lastDataAt) : '-'}
@@ -187,6 +200,11 @@ function EdgeCard({ Состояние байпасов + {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} + {/* */} + + + + {pages.isError ? ( +
Не удалось загрузить список опубликованных схем: {String(pages.error)}
+ ) : null} + + {!pages.isPending && !publishedPages.length && !pages.isError ? ( +
Для {edgeId} пока нет опубликованных схем
+ ) : null} + + {page.isError ? ( +
Не удалось загрузить опубликованную схему: {String(page.error)}
+ ) : null} + + {document ? ( +
+
+
+ {document.background?.url ? ( + + ) : null} + + + {(document.edges ?? []).map((edge) => { + const points = getEdgePoints(edge, nodeMap); + if (points.length < 2) { + return null; + } + + return ( + + ); + })} + + + {document.nodes.map((node) => ( + + ))} +
+
+ + +
+ ) : page.isPending || pages.isPending ? ( +
Загрузка опубликованной схемы...
+ ) : null} + + ); +} + +function ElectricalNode({ + node, + ownerEdgeId, + liveValues, +}: { + node: DiagramNode; + ownerEdgeId: string; + liveValues: Map; +}) { + const size = getNodeSize(node); + const bindings = node.kind === 'decoration' ? node.bindings : undefined; + const bindingEdgeId = bindings?.sseEdgeId || bindings?.edgeId || ownerEdgeId; + const mainValue = + node.kind === 'tagWidget' + ? getLiveValue(liveValues, node.edgeId || ownerEdgeId, node.tagId) + : getLiveValue(liveValues, bindingEdgeId, bindings?.stateTagId); + const alarmValue = node.kind === 'decoration' ? getLiveValue(liveValues, bindingEdgeId, bindings?.alarmTagId) : undefined; + const alarmActive = Boolean(alarmValue && Number(alarmValue.value) !== 0); + const stateActive = Boolean(mainValue && Number(mainValue.value) !== 0); + const title = getNodeTitle(node, mainValue); + const style: CSSProperties = { + left: node.position.x, + top: node.position.y, + width: size.width, + height: size.height, + zIndex: node.zIndex ?? 1, + transform: node.kind === 'decoration' && node.rotation ? `rotate(${node.rotation}deg)` : undefined, + }; + + return ( +
+
+ {alarmActive ? : node.kind === 'tagWidget' ? : } + {title} +
+ + {mainValue ? ( + + {formatNumber(mainValue.value)} + {mainValue.unitOfMeasurement ? {mainValue.unitOfMeasurement} : null} + + ) : node.kind === 'decoration' ? ( + {String(node.data?.text || node.data?.title || node.decorationType)} + ) : ( + - + )} + +
+ {node.kind === 'tagWidget' ? {node.tagId} : bindings?.stateTagId ? {bindings.stateTagId} : {node.decorationType}} + {alarmValue ? alarm {formatNumber(alarmValue.value)} : null} +
+
+ ); +} diff --git a/src/styles/charts.css b/src/styles/charts.css index 8129a34..66bcfc2 100644 --- a/src/styles/charts.css +++ b/src/styles/charts.css @@ -18,6 +18,38 @@ border-radius: var(--radius); } +.chart-tooltip { + min-width: 240px; +} + +.chart-tooltip-title { + margin-bottom: 8px; + color: #e5e7eb; + font-weight: 750; +} + +.chart-tooltip-row { + display: grid; + grid-template-columns: minmax(90px, 1fr) auto; + align-items: center; + gap: 14px; + margin-top: 5px; +} + +.chart-tooltip-row span { + min-width: 0; + overflow: hidden; + color: #cbd5e1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chart-tooltip-row strong { + color: #f8fafc; + font-variant-numeric: tabular-nums; + font-weight: 650; +} + .chart-section--archive .chart-placeholder { flex: 1; min-height: 520px; diff --git a/src/styles/detail.css b/src/styles/detail.css index 793c46d..4767eec 100644 --- a/src/styles/detail.css +++ b/src/styles/detail.css @@ -3,6 +3,33 @@ gap: 18px; } +.current-transport { + display: inline-flex; + align-items: center; + min-height: 24px; + margin-top: 8px; + padding: 0 9px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 999px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.current-transport--sse { + border-color: rgba(34, 197, 94, 0.34); + background: rgba(34, 197, 94, 0.1); + color: #86efac; +} + +.current-transport--polling { + border-color: rgba(245, 158, 11, 0.32); + background: rgba(245, 158, 11, 0.08); + color: #facc15; +} + .chart-section, .tags-section { border: 1px solid var(--line); @@ -193,12 +220,266 @@ background: #05080d; } +.electrical-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 30%), + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.022) 0 1px, transparent 1px 12px), + rgba(10, 13, 18, 0.72); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 18px 36px rgba(0, 0, 0, 0.24); +} + +.electrical-toolbar { + display: flex; + align-items: end; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.electrical-toolbar label { + min-width: 260px; +} + +.electrical-live-chip--sse { + border-color: rgba(34, 197, 94, 0.34); + background: rgba(34, 197, 94, 0.1); + color: #86efac; +} + +.electrical-layout { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 220px; + gap: 14px; + flex: 1; +} + +.electrical-canvas-shell { + min-width: 0; + min-height: 0; + overflow: auto; + border: 1px solid rgba(88, 103, 121, 0.5); + border-radius: 8px; + background: + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + #070b12; + background-size: 28px 28px; +} + +.electrical-canvas { + position: relative; + min-width: 100%; + min-height: 100%; +} + +.electrical-background { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.electrical-background--contain { + object-fit: contain; +} + +.electrical-background--cover { + object-fit: cover; +} + +.electrical-background--stretch { + object-fit: fill; +} + +.electrical-wires { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; +} + +.electrical-wire { + fill: none; + stroke: #94a3b8; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.9; +} + +.electrical-wire--power { + stroke: #f97316; + stroke-width: 3; +} + +.electrical-wire--signal { + stroke: #38bdf8; + stroke-dasharray: 8 6; +} + +.electrical-wire--alert { + stroke: #ef4444; + stroke-dasharray: 5 5; +} + +.electrical-wire--animated { + animation: electricalWireFlow 900ms linear infinite; +} + +.electrical-node { + position: absolute; + z-index: 2; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 6px; + overflow: hidden; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 7px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 32%), + rgba(15, 23, 42, 0.9); + color: #e5e7eb; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 12px 24px rgba(0, 0, 0, 0.28); +} + +.electrical-node--decoration { + border-style: dashed; + background: + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.026) 0 1px, transparent 1px 10px), + rgba(15, 23, 42, 0.74); +} + +.electrical-node--active { + border-color: rgba(34, 197, 94, 0.58); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 0 0 1px rgba(34, 197, 94, 0.2), + 0 0 22px rgba(34, 197, 94, 0.16); +} + +.electrical-node--alarm { + border-color: rgba(239, 68, 68, 0.76); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 0 0 1px rgba(239, 68, 68, 0.24), + 0 0 26px rgba(239, 68, 68, 0.22); +} + +.electrical-node__header, +.electrical-node__footer { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.electrical-node__header span, +.electrical-node__footer span, +.electrical-node__text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.electrical-node__header { + color: #f8fafc; + font-size: 0.78rem; + font-weight: 750; +} + +.electrical-node__value { + align-self: center; + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: clamp(1.2rem, 2vw, 1.9rem); + font-variant-numeric: tabular-nums; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.electrical-node__value small { + margin-left: 6px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 650; +} + +.electrical-node__text { + align-self: center; + color: #cbd5e1; + font-size: 0.9rem; +} + +.electrical-node__footer { + color: var(--muted); + font-size: 0.68rem; +} + +.electrical-node__alarm { + margin-left: auto; + color: #fca5a5; +} + +.electrical-side-panel { + display: grid; + align-content: start; + gap: 10px; +} + +.electrical-side-panel div { + min-height: 82px; + padding: 12px; + border: 1px solid rgba(88, 103, 121, 0.46); + border-radius: 8px; + background: rgba(2, 6, 23, 0.32); +} + +.electrical-side-panel span { + display: block; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.electrical-side-panel strong { + display: block; + margin-top: 10px; + color: #f8fafc; + font-size: 1.18rem; + line-height: 1.2; +} + @keyframes equipmentFrameSpin { to { transform: rotate(360deg); } } +@keyframes electricalWireFlow { + to { + stroke-dashoffset: -28; + } +} + .section-header { justify-content: space-between; margin-bottom: 16px; @@ -394,6 +675,14 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .electrical-layout { + grid-template-columns: 1fr; + } + + .electrical-side-panel { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .tag-selector__header { grid-template-columns: 1fr; } @@ -419,4 +708,17 @@ flex-direction: column; align-items: stretch; } + + .electrical-side-panel { + grid-template-columns: 1fr 1fr; + } + + .electrical-toolbar { + align-items: stretch; + flex-direction: column; + } + + .electrical-toolbar label { + min-width: 0; + } } diff --git a/src/styles/layout.css b/src/styles/layout.css index dab67b0..e2690c5 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -146,7 +146,8 @@ } .workspace--archive, -.workspace--equipment { +.workspace--equipment, +.workspace--electrical { display: flex; flex-direction: column; overflow: auto; diff --git a/src/utils/format.ts b/src/utils/format.ts index 6f880e9..2f27483 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,7 +1,7 @@ /** Форматирует числовое значение показателя для карточек и мини-плиток. */ export function formatNumber(value: number): string { if (!Number.isFinite(value)) { - return '—'; + return '-'; } return new Intl.NumberFormat('ru-RU', { @@ -9,11 +9,11 @@ export function formatNumber(value: number): string { }).format(value); } -/** Приводит дату/время к короткому русскому формату для операторского интерфейса. */ +/** Приводит дату и время к короткому русскому формату для операторского интерфейса. */ export function formatDateTime(value: string | number | Date): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { - return '—'; + return '-'; } return new Intl.DateTimeFormat('ru-RU', { @@ -27,7 +27,6 @@ export function formatDateTime(value: string | number | Date): string { /** Конвертирует Date в формат, который ожидает input[type="datetime-local"]. */ export function toInputDateTimeValue(date: Date): string { - /** Дополняет часть даты ведущим нулем для стабильного input-формата. */ const pad = (part: number) => String(part).padStart(2, '0'); return [ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,