From 31add10e563a6388ef52b17ff06a38ba32d526a0 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: Sat, 20 Jun 2026 00:51:18 +0300 Subject: [PATCH 1/4] decompose structure --- .dockerignore | 4 + .env.example | 5 +- Dockerfile | 14 +- docker-compose.yml | 8 +- src/App.tsx | 35 +- src/api/cloud.ts | 169 +--- src/api/diagram.ts | 135 +--- src/app/App.tsx | 5 + src/app/providers.tsx | 21 + src/app/router.tsx | 32 + src/auth/authActions.ts | 116 +++ src/auth/authStore.ts | 58 ++ src/auth/keycloak.ts | 186 +---- src/auth/keycloakClient.ts | 12 + src/components/HistoryChart.tsx | 333 +------- src/components/MetricCard.tsx | 2 +- src/entities/current/api.ts | 16 + src/entities/current/types.ts | 22 + src/entities/diagram/api.ts | 22 + src/entities/diagram/types.ts | 88 +++ src/entities/edge/api.ts | 8 + src/entities/edge/types.ts | 11 + src/entities/health/api.ts | 8 + src/entities/health/types.ts | 8 + src/entities/history/api.ts | 8 + src/entities/history/types.ts | 30 + src/entities/tag/api.ts | 8 + src/entities/tag/types.ts | 15 + src/features/current/model.ts | 37 + src/features/current/useCurrentEvents.ts | 32 + src/features/current/useCurrentQuery.ts | 11 + src/features/edge-detail/EdgeDetailPage.tsx | 137 ++++ .../edge-detail/components/ArchiveView.tsx | 186 +++++ .../edge-detail/components/EdgeSidebar.tsx | 70 ++ .../edge-detail/components/EdgeTopbar.tsx | 46 ++ .../edge-detail/components/EquipmentView.tsx | 29 + .../edge-detail/components/IndicatorsView.tsx | 133 ++++ .../edge-detail/components/OverviewView.tsx | 72 ++ src/features/edge-detail/types.ts | 1 + .../edges-dashboard/EdgesDashboard.tsx | 162 ++++ .../electrical-schematics/ElectricalNode.tsx | 78 ++ .../ElectricalSchematicsPage.tsx | 162 ++++ .../electrical-schematics/geometry.ts | 42 + src/features/electrical-schematics/model.ts | 90 +++ src/features/electrical-schematics/types.ts | 19 + .../useElectricalLiveValues.ts | 83 ++ src/features/history-chart/HistoryChart.tsx | 92 +++ src/features/history-chart/chartTypes.ts | 18 + .../history-chart/historyChartFormat.ts | 102 +++ .../history-chart/historyChartOptions.ts | 110 +++ .../history-chart/historyChartSeries.ts | 101 +++ src/features/history/dateRange.ts | 23 + src/features/history/useHistoryQuery.ts | 28 + src/integrations/toir.ts | 10 +- src/main.tsx | 21 +- src/pages/EdgeDetailPage.tsx | 725 +---------------- src/pages/EdgesDashboard.tsx | 236 +----- src/pages/ElectricalSchematicsPage.tsx | 477 +----------- src/shared/api/http.ts | 30 + src/shared/config/env.ts | 6 + src/styles/components.css | 546 +------------ src/styles/components/auth.css | 43 ++ src/styles/components/controls.css | 158 ++++ src/styles/components/metrics.css | 324 ++++++++ src/styles/dashboard.css | 413 +--------- src/styles/dashboard/edge-card.css | 210 +++++ src/styles/dashboard/layout.css | 116 +++ src/styles/detail.css | 730 +----------------- src/styles/detail/archive.css | 194 +++++ src/styles/detail/base.css | 45 ++ src/styles/detail/electrical.css | 278 +++++++ src/styles/detail/equipment.css | 57 ++ src/styles/detail/indicators.css | 78 ++ src/styles/detail/overview.css | 81 ++ src/utils/historyGranularity.ts | 62 ++ src/utils/metricStatus.ts | 2 +- src/vite-env.d.ts | 1 + 77 files changed, 4112 insertions(+), 3974 deletions(-) create mode 100644 .dockerignore create mode 100644 src/app/App.tsx create mode 100644 src/app/providers.tsx create mode 100644 src/app/router.tsx create mode 100644 src/auth/authActions.ts create mode 100644 src/auth/authStore.ts create mode 100644 src/auth/keycloakClient.ts create mode 100644 src/entities/current/api.ts create mode 100644 src/entities/current/types.ts create mode 100644 src/entities/diagram/api.ts create mode 100644 src/entities/diagram/types.ts create mode 100644 src/entities/edge/api.ts create mode 100644 src/entities/edge/types.ts create mode 100644 src/entities/health/api.ts create mode 100644 src/entities/health/types.ts create mode 100644 src/entities/history/api.ts create mode 100644 src/entities/history/types.ts create mode 100644 src/entities/tag/api.ts create mode 100644 src/entities/tag/types.ts create mode 100644 src/features/current/model.ts create mode 100644 src/features/current/useCurrentEvents.ts create mode 100644 src/features/current/useCurrentQuery.ts create mode 100644 src/features/edge-detail/EdgeDetailPage.tsx create mode 100644 src/features/edge-detail/components/ArchiveView.tsx create mode 100644 src/features/edge-detail/components/EdgeSidebar.tsx create mode 100644 src/features/edge-detail/components/EdgeTopbar.tsx create mode 100644 src/features/edge-detail/components/EquipmentView.tsx create mode 100644 src/features/edge-detail/components/IndicatorsView.tsx create mode 100644 src/features/edge-detail/components/OverviewView.tsx create mode 100644 src/features/edge-detail/types.ts create mode 100644 src/features/edges-dashboard/EdgesDashboard.tsx create mode 100644 src/features/electrical-schematics/ElectricalNode.tsx create mode 100644 src/features/electrical-schematics/ElectricalSchematicsPage.tsx create mode 100644 src/features/electrical-schematics/geometry.ts create mode 100644 src/features/electrical-schematics/model.ts create mode 100644 src/features/electrical-schematics/types.ts create mode 100644 src/features/electrical-schematics/useElectricalLiveValues.ts create mode 100644 src/features/history-chart/HistoryChart.tsx create mode 100644 src/features/history-chart/chartTypes.ts create mode 100644 src/features/history-chart/historyChartFormat.ts create mode 100644 src/features/history-chart/historyChartOptions.ts create mode 100644 src/features/history-chart/historyChartSeries.ts create mode 100644 src/features/history/dateRange.ts create mode 100644 src/features/history/useHistoryQuery.ts create mode 100644 src/shared/api/http.ts create mode 100644 src/shared/config/env.ts create mode 100644 src/styles/components/auth.css create mode 100644 src/styles/components/controls.css create mode 100644 src/styles/components/metrics.css create mode 100644 src/styles/dashboard/edge-card.css create mode 100644 src/styles/dashboard/layout.css create mode 100644 src/styles/detail/archive.css create mode 100644 src/styles/detail/base.css create mode 100644 src/styles/detail/electrical.css create mode 100644 src/styles/detail/equipment.css create mode 100644 src/styles/detail/indicators.css create mode 100644 src/styles/detail/overview.css create mode 100644 src/utils/historyGranularity.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69e11c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +.env.* diff --git a/.env.example b/.env.example index 28cc5bb..3807ba6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ VITE_CLOUD_API_URL=http://localhost:3101 VITE_DIAGRAM_API_URL=http://localhost:3002 -VITE_DEFAULT_EDGE=edge5 +VITE_TOIR_LIGHT_ORIGIN=https://toir-light.greact.ru +VITE_KEYCLOAK_URL= +VITE_KEYCLOAK_REALM= +VITE_KEYCLOAK_CLIENT_ID= diff --git a/Dockerfile b/Dockerfile index 64dcbad..12b75ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,18 @@ FROM node:22-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -ARG VITE_CLOUD_API_URL=http://localhost:3101 -ARG VITE_DEFAULT_EDGE=edge5 +ARG VITE_CLOUD_API_URL +ARG VITE_DIAGRAM_API_URL +ARG VITE_TOIR_LIGHT_ORIGIN +ARG VITE_KEYCLOAK_URL +ARG VITE_KEYCLOAK_REALM +ARG VITE_KEYCLOAK_CLIENT_ID ENV VITE_CLOUD_API_URL=$VITE_CLOUD_API_URL -ENV VITE_DEFAULT_EDGE=$VITE_DEFAULT_EDGE +ENV VITE_DIAGRAM_API_URL=$VITE_DIAGRAM_API_URL +ENV VITE_TOIR_LIGHT_ORIGIN=$VITE_TOIR_LIGHT_ORIGIN +ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL +ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM +ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID RUN npm run build FROM nginx:1.27-alpine diff --git a/docker-compose.yml b/docker-compose.yml index 9a1e3ce..2af8e95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,12 @@ services: build: context: . args: - VITE_CLOUD_API_URL: ${VITE_CLOUD_API_URL:-http://localhost:3101} - VITE_DEFAULT_EDGE: ${VITE_DEFAULT_EDGE:-edge5} + VITE_CLOUD_API_URL: ${VITE_CLOUD_API_URL} + VITE_DIAGRAM_API_URL: ${VITE_DIAGRAM_API_URL} + VITE_TOIR_LIGHT_ORIGIN: ${VITE_TOIR_LIGHT_ORIGIN} + VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL} + VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM} + VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID} expose: - "80" diff --git a/src/App.tsx b/src/App.tsx index 6529772..4de4bf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1 @@ -import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom'; -import { EdgeDetailPage } from './pages/EdgeDetailPage'; -import { EdgesDashboard } from './pages/EdgesDashboard'; - -/** Связывает dashboard со SPA-навигацией без передачи navigate в саму страницу. */ -function DashboardRoute() { - const navigate = useNavigate(); - - return ( - navigate(`/edges/${encodeURIComponent(edgeId)}`)} - onOpenEquipment={(edgeId) => navigate(`/edges/${encodeURIComponent(edgeId)}/equipment`)} - /> - ); -} - -/** Описывает публичную карту маршрутов приложения вокруг edge-объектов. */ -export default function App() { - return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} - {/* } /> */} - } /> - - - ); -} +export { default } from './app/App'; diff --git a/src/api/cloud.ts b/src/api/cloud.ts index bf2c42b..5f042e1 100644 --- a/src/api/cloud.ts +++ b/src/api/cloud.ts @@ -1,159 +1,10 @@ -const API_URL = import.meta.env.VITE_CLOUD_API_URL ?? 'http://localhost:3101'; - -export type HealthResponse = { - status: 'ok'; - database: { - now: string; - timescaledb_installed: boolean; - timescaledb_version: string | null; - }; -}; - -export type CurrentItem = { - edge: string; - tag: string; - value: number; - 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 = { - edge: string; - items: CurrentItem[]; -}; - -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; -}; - -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; - min: number; - avg: number; - max: number; - count: number; -}; - -export type HistorySeries = { - edge: string; - tag: string; - points: HistoryPoint[]; -}; - -export type HistoryResponse = { - edge: string; - source: 'empty' | 'latest' | 'raw' | '1m' | '5m' | '1h' | '1d'; - targetPoints: number; - series: HistorySeries[]; - from?: string; - to?: string; - resolutionSeconds?: number | null; -}; - -/** Выполняет типизированный 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]) => { - if (value !== undefined && value !== '') { - url.searchParams.set(key, String(value)); - } - }); - - const response = await fetch(url); - if (!response.ok) { - const message = await response.text(); - throw new Error(message || `${response.status} ${response.statusText}`); - } - - return response.json() as Promise; -} - -/** Запрашивает состояние cloud-v3 и подключенной базы данных. */ -export function getHealth(): Promise { - return request('/health'); -} - -/** Возвращает список edge-установок, доступных в cloud-v3. */ -export function getEdges(): Promise { - return request('/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, - tags: tags?.join(','), - }); -} - -/** Формирует URL SSE-потока текущих значений для выбранного edge. */ -export function getCurrentEventsUrl(edge: string, tags?: string[]): string { - const url = new URL('/current/events', API_URL); - url.searchParams.set('edge', edge); - - if (tags?.length) { - url.searchParams.set('tags', tags.join(',')); - } - - return url.toString(); -} - -/** Запрашивает исторические ряды с avg-линией и min/max-диапазоном для графика. */ -export function getHistory(params: { - edge: string; - tags?: string[]; - from?: string; - to?: string; - targetPoints?: number; -}): Promise { - return request('/history', { - edge: params.edge, - tags: params.tags?.join(','), - from: params.from, - to: params.to, - targetPoints: params.targetPoints, - }); -} +export { getCurrent, getCurrentEventsUrl } from '../entities/current/api'; +export type { CurrentEvent, CurrentItem, CurrentResponse } from '../entities/current/types'; +export { getEdges } from '../entities/edge/api'; +export type { EdgeItem, EdgeResponse } from '../entities/edge/types'; +export { getHealth } from '../entities/health/api'; +export type { HealthResponse } from '../entities/health/types'; +export { getHistory } from '../entities/history/api'; +export type { HistoryPoint, HistoryRequest, HistoryResponse, HistorySeries } from '../entities/history/types'; +export { getTags } from '../entities/tag/api'; +export type { TagItem, TagResponse } from '../entities/tag/types'; diff --git a/src/api/diagram.ts b/src/api/diagram.ts index 67e9260..4d8894e 100644 --- a/src/api/diagram.ts +++ b/src/api/diagram.ts @@ -1,124 +1,11 @@ -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`); -} +export { getPublishedDiagramPage, listDiagramPages } from '../entities/diagram/api'; +export type { + DiagramBindingConfig, + DiagramDocument, + DiagramEdge, + DiagramNode, + DiagramPageResponse, + DiagramPageSummary, + DiagramPoint, + DiagramSize, +} from '../entities/diagram/types'; diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..2e7c9bc --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,5 @@ +import { AppRouter } from './router'; + +export default function App() { + return ; +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..1a77490 --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,21 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { PropsWithChildren } from 'react'; +import { AuthProvider } from '../auth/AuthProvider'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +export function AppProviders({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/src/app/router.tsx b/src/app/router.tsx new file mode 100644 index 0000000..419a2c5 --- /dev/null +++ b/src/app/router.tsx @@ -0,0 +1,32 @@ +import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom'; +import { EdgeDetailPage } from '../features/edge-detail/EdgeDetailPage'; +import { EdgesDashboard } from '../features/edges-dashboard/EdgesDashboard'; + +function DashboardRoute() { + const navigate = useNavigate(); + + return ( + navigate(`/edges/${encodeURIComponent(edgeId)}`)} + onOpenEquipment={(edgeId) => navigate(`/edges/${encodeURIComponent(edgeId)}/equipment`)} + /> + ); +} + +export function AppRouter() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} + {/* } /> */} + } /> + + + ); +} diff --git a/src/auth/authActions.ts b/src/auth/authActions.ts new file mode 100644 index 0000000..36bf942 --- /dev/null +++ b/src/auth/authActions.ts @@ -0,0 +1,116 @@ +import type { KeycloakInitOptions } from 'keycloak-js'; +import { keycloak } from './keycloakClient'; +import { getAuthState, getIdentityState, setAuthState, type AuthState } from './authStore'; + +let initPromise: Promise | null = null; +let refreshPromise: Promise | null = null; + +/** Инициализирует Keycloak один раз и запускает login-required flow при включенном SSO. */ +export async function initAuth(): Promise { + if (!keycloak) { + return getAuthState(); + } + + if (!initPromise) { + const initOptions: KeycloakInitOptions = { + onLoad: 'login-required', + pkceMethod: 'S256', + checkLoginIframe: false, + }; + + initPromise = keycloak.init(initOptions).then((authenticated) => { + setAuthState({ + initialized: true, + authenticated, + ...getIdentityState(), + }); + + return authenticated; + }); + } + + await initPromise; + return getAuthState(); +} + +/** Обновляет access token перед API-запросами и повторно использует текущий refresh-promise. */ +export async function refreshToken(minValidity = 30): Promise { + if (!keycloak) { + return null; + } + + const client = keycloak; + + if (!refreshPromise) { + refreshPromise = client + .updateToken(minValidity) + .then(() => { + setAuthState({ + authenticated: Boolean(client.authenticated), + ...getIdentityState(), + }); + + return client.token ?? null; + }) + .catch(async () => { + setAuthState({ + authenticated: false, + username: null, + fullName: null, + email: null, + }); + + await login(); + return null; + }) + .finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; +} + +/** Возвращает действующий access token или null, если SSO отключен/пользователь не вошел. */ +export async function getAccessToken(): Promise { + if (!keycloak?.authenticated) { + return null; + } + + await refreshToken(30); + return keycloak.token ?? null; +} + +/** Запускает ручной вход через Keycloak с возвратом на текущий адрес. */ +export async function login(redirectUri = window.location.href): Promise { + await keycloak?.login({ redirectUri }); +} + +/** Завершает Keycloak-сессию и возвращает пользователя на origin приложения. */ +export async function logout(redirectUri = window.location.origin): Promise { + await keycloak?.logout({ redirectUri }); +} + +if (keycloak) { + keycloak.onAuthSuccess = () => { + setAuthState({ + initialized: true, + authenticated: true, + ...getIdentityState(), + }); + }; + + keycloak.onAuthLogout = () => { + setAuthState({ + initialized: true, + authenticated: false, + username: null, + fullName: null, + email: null, + }); + }; + + keycloak.onTokenExpired = () => { + void refreshToken(30); + }; +} diff --git a/src/auth/authStore.ts b/src/auth/authStore.ts new file mode 100644 index 0000000..3093f07 --- /dev/null +++ b/src/auth/authStore.ts @@ -0,0 +1,58 @@ +import { authEnabled, keycloak } from './keycloakClient'; + +export type AuthState = { + initialized: boolean; + authenticated: boolean; + enabled: boolean; + username: string | null; + fullName: string | null; + email: string | null; +}; + +type AuthListener = (state: AuthState) => void; + +const defaultState: AuthState = { + initialized: !authEnabled, + authenticated: !authEnabled, + enabled: authEnabled, + username: null, + fullName: null, + email: null, +}; + +let authState = defaultState; +const listeners = new Set(); + +/** Извлекает пользовательскую идентичность из распарсенного Keycloak-токена. */ +export function getIdentityState(): Pick { + const tokenParsed = keycloak?.tokenParsed as + | { preferred_username?: string; name?: string; email?: string } + | undefined; + + return { + username: tokenParsed?.preferred_username ?? null, + fullName: tokenParsed?.name ?? null, + email: tokenParsed?.email ?? null, + }; +} + +/** Обновляет auth-состояние и уведомляет все React-подписки. */ +export function setAuthState(partial: Partial) { + authState = { ...authState, ...partial }; + listeners.forEach((listener) => listener(authState)); +} + +/** Возвращает актуальный снимок auth-состояния без подписки. */ +export function getAuthState(): AuthState { + return authState; +} + +/** Подписывает UI на изменения Keycloak-сессии и возвращает функцию отписки. */ +export function subscribeAuth(listener: AuthListener): () => void { + listeners.add(listener); + listener(authState); + + return () => { + listeners.delete(listener); + }; +} diff --git a/src/auth/keycloak.ts b/src/auth/keycloak.ts index b0a4be0..e5a98a3 100644 --- a/src/auth/keycloak.ts +++ b/src/auth/keycloak.ts @@ -1,183 +1,3 @@ -import Keycloak, { type KeycloakInitOptions } from 'keycloak-js'; - -const keycloakUrl = import.meta.env.VITE_KEYCLOAK_URL; -const keycloakRealm = import.meta.env.VITE_KEYCLOAK_REALM; -const keycloakClientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID; - -const authEnabled = Boolean(keycloakUrl && keycloakRealm && keycloakClientId); - -const keycloak = authEnabled - ? new Keycloak({ - url: keycloakUrl!, - realm: keycloakRealm!, - clientId: keycloakClientId!, - }) - : null; - -let initPromise: Promise | null = null; -let refreshPromise: Promise | null = null; - -export type AuthState = { - initialized: boolean; - authenticated: boolean; - enabled: boolean; - username: string | null; - fullName: string | null; - email: string | null; -}; - -type AuthListener = (state: AuthState) => void; - -const defaultState: AuthState = { - initialized: !authEnabled, - authenticated: !authEnabled, - enabled: authEnabled, - username: null, - fullName: null, - email: null, -}; - -let authState = defaultState; -const listeners = new Set(); - -/** Извлекает пользовательскую идентичность из распарсенного Keycloak-токена. */ -function getIdentityState(): Pick { - const tokenParsed = keycloak?.tokenParsed as - | { preferred_username?: string; name?: string; email?: string } - | undefined; - - return { - username: tokenParsed?.preferred_username ?? null, - fullName: tokenParsed?.name ?? null, - email: tokenParsed?.email ?? null, - }; -} - -/** Обновляет auth-состояние и уведомляет все React-подписки. */ -function setAuthState(partial: Partial) { - authState = { ...authState, ...partial }; - listeners.forEach((listener) => listener(authState)); -} - -if (keycloak) { - keycloak.onAuthSuccess = () => { - setAuthState({ - initialized: true, - authenticated: true, - ...getIdentityState(), - }); - }; - - keycloak.onAuthLogout = () => { - setAuthState({ - initialized: true, - authenticated: false, - username: null, - fullName: null, - email: null, - }); - }; - - keycloak.onTokenExpired = () => { - void refreshToken(30); - }; -} - -/** Возвращает актуальный снимок auth-состояния без подписки. */ -export function getAuthState(): AuthState { - return authState; -} - -/** Подписывает UI на изменения Keycloak-сессии и возвращает функцию отписки. */ -export function subscribeAuth(listener: AuthListener): () => void { - listeners.add(listener); - listener(authState); - - return () => { - listeners.delete(listener); - }; -} - -/** Инициализирует Keycloak один раз и запускает login-required flow при включенном SSO. */ -export async function initAuth(): Promise { - if (!keycloak) { - return authState; - } - - if (!initPromise) { - const initOptions: KeycloakInitOptions = { - onLoad: 'login-required', - pkceMethod: 'S256', - checkLoginIframe: false, - }; - - initPromise = keycloak.init(initOptions).then((authenticated) => { - setAuthState({ - initialized: true, - authenticated, - ...getIdentityState(), - }); - - return authenticated; - }); - } - - await initPromise; - return authState; -} - -/** Обновляет access token перед API-запросами и повторно использует текущий refresh-promise. */ -export async function refreshToken(minValidity = 30): Promise { - if (!keycloak) { - return null; - } - - if (!refreshPromise) { - refreshPromise = keycloak - .updateToken(minValidity) - .then(() => { - setAuthState({ - authenticated: Boolean(keycloak.authenticated), - ...getIdentityState(), - }); - - return keycloak.token ?? null; - }) - .catch(async () => { - setAuthState({ - authenticated: false, - username: null, - fullName: null, - email: null, - }); - - await login(); - return null; - }) - .finally(() => { - refreshPromise = null; - }); - } - - return refreshPromise; -} - -/** Возвращает действующий access token или null, если SSO отключен/пользователь не вошел. */ -export async function getAccessToken(): Promise { - if (!keycloak?.authenticated) { - return null; - } - - await refreshToken(30); - return keycloak.token ?? null; -} - -/** Запускает ручной вход через Keycloak с возвратом на текущий адрес. */ -export async function login(redirectUri = window.location.href): Promise { - await keycloak?.login({ redirectUri }); -} - -/** Завершает Keycloak-сессию и возвращает пользователя на origin приложения. */ -export async function logout(redirectUri = window.location.origin): Promise { - await keycloak?.logout({ redirectUri }); -} +export { getAuthState, subscribeAuth } from './authStore'; +export type { AuthState } from './authStore'; +export { getAccessToken, initAuth, login, logout, refreshToken } from './authActions'; diff --git a/src/auth/keycloakClient.ts b/src/auth/keycloakClient.ts new file mode 100644 index 0000000..8c0cdcc --- /dev/null +++ b/src/auth/keycloakClient.ts @@ -0,0 +1,12 @@ +import Keycloak from 'keycloak-js'; +import { keycloakClientId, keycloakRealm, keycloakUrl } from '../shared/config/env'; + +export const authEnabled = Boolean(keycloakUrl && keycloakRealm && keycloakClientId); + +export const keycloak = authEnabled + ? new Keycloak({ + url: keycloakUrl as string, + realm: keycloakRealm as string, + clientId: keycloakClientId as string, + }) + : null; diff --git a/src/components/HistoryChart.tsx b/src/components/HistoryChart.tsx index 6e3f78b..7d6882a 100644 --- a/src/components/HistoryChart.tsx +++ b/src/components/HistoryChart.tsx @@ -1,332 +1 @@ -import { LineChart, ScatterChart } from 'echarts/charts'; -import { - DataZoomComponent, - GridComponent, - LegendComponent, - TooltipComponent, -} from 'echarts/components'; -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 { useMemo, useRef } from 'react'; -import type { HistoryResponse } from '../api/cloud'; - -echarts.use([ - LineChart, - ScatterChart, - GridComponent, - LegendComponent, - TooltipComponent, - DataZoomComponent, - CanvasRenderer, -]); - -const SERIES_COLORS = [ - '#5B8FF9', - '#5AD8A6', - '#F6BD16', - '#E8684A', - '#6DC8EC', - '#FF9D4D', - '#73D13D', - '#A7B3FF', -]; - -type HistoryChartProps = { - data?: HistoryResponse; - loading: boolean; - from?: string; - to?: string; - tagLabels?: Record; -}; - -type AvgPointValue = [time: number, value: number, min: number, avg: number, max: number, count: number]; - -type TooltipParam = { - marker?: string; - seriesName?: string; - value?: unknown; -}; - -type DataZoomState = { - start?: number; - end?: number; - startValue?: number; - endValue?: number; -}; - -type DataZoomEventBatch = DataZoomState & { - batch?: DataZoomState[]; -}; - -/** Подбирает подпись оси времени под текущий масштаб графика. */ -function formatAxisDate(value: number, spanMs?: number): string { - const date = new Date(value); - - if (!spanMs || spanMs <= 24 * 60 * 60 * 1000) { - return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date); - } - - if (spanMs <= 7 * 24 * 60 * 60 * 1000) { - return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit' }).format(date); - } - - return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date); -} - -/** Форматирует числовое значение для 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 dataZoomRef = useRef(null); - 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 = useMemo( - () => data?.series.map((series) => tagLabels[series.tag] ?? series.tag) ?? [], - [data?.series, tagLabels], - ); - const dataZoomState = dataZoomRef.current ?? {}; - - const option: EChartsOption = useMemo(() => ({ - color: SERIES_COLORS, - animation: false, - backgroundColor: 'transparent', - textStyle: { - color: '#cbd5e1', - fontFamily: 'Inter, Segoe UI, sans-serif', - }, - tooltip: { - trigger: 'axis', - backgroundColor: 'rgba(10, 13, 18, 0.96)', - borderColor: 'rgba(212, 165, 116, 0.32)', - textStyle: { color: '#f8fafc' }, - formatter: formatTooltip, - }, - legend: { - type: 'scroll', - top: 0, - data: legendData, - textStyle: { color: '#cbd5e1' }, - }, - grid: { - top: 54, - left: 48, - right: 24, - bottom: 72, - }, - xAxis: { - type: 'time', - min: Number.isFinite(xMin) ? xMin : undefined, - max: Number.isFinite(xMax) ? xMax : undefined, - axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } }, - axisLabel: { - color: '#94a3b8', - formatter: (value: number) => formatAxisDate(value, xSpan), - }, - splitLine: { show: false }, - }, - yAxis: { - type: 'value', - scale: true, - axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } }, - axisLabel: { color: '#94a3b8' }, - splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } }, - }, - dataZoom: [ - { - type: 'inside', - throttle: 80, - start: dataZoomState.start, - end: dataZoomState.end, - startValue: dataZoomState.startValue, - endValue: dataZoomState.endValue, - }, - { - type: 'slider', - bottom: 18, - height: 28, - borderColor: 'rgba(148, 163, 184, 0.24)', - fillerColor: 'rgba(91, 143, 249, 0.18)', - handleStyle: { - color: '#d4a574', - borderColor: '#e8c9a0', - }, - textStyle: { color: '#94a3b8' }, - start: dataZoomState.start, - end: dataZoomState.end, - startValue: dataZoomState.startValue, - endValue: dataZoomState.endValue, - }, - ], - series: - data?.series.flatMap((series, index) => - createSeriesOptions(series, index, tagLabels[series.tag] ?? series.tag), - ) ?? [], - }), [data?.series, dataZoomState.end, dataZoomState.endValue, dataZoomState.start, dataZoomState.startValue, legendData, tagLabels, xMax, xMin, xSpan]); - - const onChartEvents = useMemo( - () => ({ - datazoom: (event: DataZoomEventBatch) => { - const state = event.batch?.[0] ?? event; - dataZoomRef.current = { - start: state.start, - end: state.end, - startValue: state.startValue, - endValue: state.endValue, - }; - }, - }), - [], - ); - - if (loading) { - return
Загрузка графика...
; - } - - if (!data?.series.length) { - return
Нет данных для выбранного диапазона
; - } - - return ; -} +export { HistoryChart } from '../features/history-chart/HistoryChart'; diff --git a/src/components/MetricCard.tsx b/src/components/MetricCard.tsx index b56059e..2e7c8fd 100644 --- a/src/components/MetricCard.tsx +++ b/src/components/MetricCard.tsx @@ -1,5 +1,5 @@ import { Activity, Check, Clock3, Plus } from 'lucide-react'; -import type { CurrentItem } from '../api/cloud'; +import type { CurrentItem } from '../entities/current/types'; import { formatNumber } from '../utils/format'; import { getMetricStatus, type MetricStatusInfo } from '../utils/metricStatus'; diff --git a/src/entities/current/api.ts b/src/entities/current/api.ts new file mode 100644 index 0000000..6f14ac2 --- /dev/null +++ b/src/entities/current/api.ts @@ -0,0 +1,16 @@ +import { getJson } from '../../shared/api/http'; +import { cloudApiUrl } from '../../shared/config/env'; +import type { CurrentResponse } from './types'; + +/** Загружает текущие значения показателей для выбранной буровой. */ +export function getCurrent(edge: string, tags?: string[]): Promise { + return getJson(cloudApiUrl, '/current', { edge, tags }); +} + +/** Формирует URL SSE-потока текущих значений. */ +export function getCurrentEventsUrl(edge: string, tags?: string[]): string { + const url = new URL('/current/events', cloudApiUrl); + url.searchParams.set('edge', edge); + tags?.forEach((tag) => url.searchParams.append('tags', tag)); + return url.toString(); +} diff --git a/src/entities/current/types.ts b/src/entities/current/types.ts new file mode 100644 index 0000000..bfa31cf --- /dev/null +++ b/src/entities/current/types.ts @@ -0,0 +1,22 @@ +export type CurrentItem = { + edge: string; + tag: string; + value: number; + 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 = { + edge: string; + items: CurrentItem[]; +}; + +export type CurrentEvent = CurrentResponse; diff --git a/src/entities/diagram/api.ts b/src/entities/diagram/api.ts new file mode 100644 index 0000000..abe840d --- /dev/null +++ b/src/entities/diagram/api.ts @@ -0,0 +1,22 @@ +import { getAccessToken } from '../../auth/keycloak'; +import { getJson } from '../../shared/api/http'; +import { diagramApiUrl } from '../../shared/config/env'; +import type { DiagramPageResponse, DiagramPageSummary } from './types'; + +/** Выполняет запрос к diagram-service и пробрасывает Bearer-токен, если UI работает с SSO. */ +async function request(path: string, params?: Record): Promise { + const token = await getAccessToken(); + return getJson(diagramApiUrl, path, params, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); +} + +/** Возвращает список страниц схем; опубликованность определяется по 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/entities/diagram/types.ts b/src/entities/diagram/types.ts new file mode 100644 index 0000000..9874010 --- /dev/null +++ b/src/entities/diagram/types.ts @@ -0,0 +1,88 @@ +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; +}; diff --git a/src/entities/edge/api.ts b/src/entities/edge/api.ts new file mode 100644 index 0000000..e9a0c2a --- /dev/null +++ b/src/entities/edge/api.ts @@ -0,0 +1,8 @@ +import { getJson } from '../../shared/api/http'; +import { cloudApiUrl } from '../../shared/config/env'; +import type { EdgeResponse } from './types'; + +/** Возвращает список буровых, доступных в cloud-v3. */ +export function getEdges(): Promise { + return getJson(cloudApiUrl, '/edge'); +} diff --git a/src/entities/edge/types.ts b/src/entities/edge/types.ts new file mode 100644 index 0000000..b8c1d9f --- /dev/null +++ b/src/entities/edge/types.ts @@ -0,0 +1,11 @@ +export type EdgeItem = { + id: string; + name: string; + parentId: string | null; + tagIds: string[]; + tagCount: number; +}; + +export type EdgeResponse = { + items: EdgeItem[]; +}; diff --git a/src/entities/health/api.ts b/src/entities/health/api.ts new file mode 100644 index 0000000..0dec8d0 --- /dev/null +++ b/src/entities/health/api.ts @@ -0,0 +1,8 @@ +import { getJson } from '../../shared/api/http'; +import { cloudApiUrl } from '../../shared/config/env'; +import type { HealthResponse } from './types'; + +/** Запрашивает состояние cloud-v3 и подключенной базы данных. */ +export function getHealth(): Promise { + return getJson(cloudApiUrl, '/health'); +} diff --git a/src/entities/health/types.ts b/src/entities/health/types.ts new file mode 100644 index 0000000..4254886 --- /dev/null +++ b/src/entities/health/types.ts @@ -0,0 +1,8 @@ +export type HealthResponse = { + status: 'ok'; + database: { + now: string; + timescaledb_installed: boolean; + timescaledb_version: string | null; + }; +}; diff --git a/src/entities/history/api.ts b/src/entities/history/api.ts new file mode 100644 index 0000000..c96e79a --- /dev/null +++ b/src/entities/history/api.ts @@ -0,0 +1,8 @@ +import { getJson } from '../../shared/api/http'; +import { cloudApiUrl } from '../../shared/config/env'; +import type { HistoryRequest, HistoryResponse } from './types'; + +/** Запрашивает исторический ряд с avg-линией и min/max-диапазоном для графика. */ +export function getHistory(params: HistoryRequest): Promise { + return getJson(cloudApiUrl, '/history', params); +} diff --git a/src/entities/history/types.ts b/src/entities/history/types.ts new file mode 100644 index 0000000..bd4f0e8 --- /dev/null +++ b/src/entities/history/types.ts @@ -0,0 +1,30 @@ +export type HistoryPoint = { + t: number; + min: number; + avg: number; + max: number; + count: number; +}; + +export type HistorySeries = { + edge: string; + tag: string; + points: HistoryPoint[]; +}; + +export type HistoryResponse = { + edge: string; + tag: string; + granulate: string; + series: HistorySeries[]; + from: string; + to: string; +}; + +export type HistoryRequest = { + edge: string; + tag: string; + from: string; + to: string; + granulate: string; +}; diff --git a/src/entities/tag/api.ts b/src/entities/tag/api.ts new file mode 100644 index 0000000..54c615e --- /dev/null +++ b/src/entities/tag/api.ts @@ -0,0 +1,8 @@ +import { getJson } from '../../shared/api/http'; +import { cloudApiUrl } from '../../shared/config/env'; +import type { TagResponse } from './types'; + +/** Загружает справочник тегов; tag.name используется как отображаемое имя. */ +export function getTags(params: { edge?: string; search?: string } = {}): Promise { + return getJson(cloudApiUrl, '/tag', params); +} diff --git a/src/entities/tag/types.ts b/src/entities/tag/types.ts new file mode 100644 index 0000000..0a750c7 --- /dev/null +++ b/src/entities/tag/types.ts @@ -0,0 +1,15 @@ +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[]; +}; diff --git a/src/features/current/model.ts b/src/features/current/model.ts new file mode 100644 index 0000000..b865195 --- /dev/null +++ b/src/features/current/model.ts @@ -0,0 +1,37 @@ +import type { CurrentItem } from '../../entities/current/types'; + +export function getCurrentItemLabel(item: CurrentItem): string { + return item.name?.trim() || item.tag; +} + +export function createCurrentTagLabels(items: CurrentItem[]): Record { + return Object.fromEntries(items.map((item) => [item.tag, getCurrentItemLabel(item)])); +} + +export function filterCurrentItems(items: CurrentItem[], search: string): CurrentItem[] { + const query = search.trim().toLowerCase(); + if (!query) { + return items; + } + + return items.filter((item) => + [item.tag, item.name, item.comment, item.tagGroup, item.unitOfMeasurement] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(query), + ); +} + +export function getLatestCurrentUpdatedAt(items: CurrentItem[]): Date | undefined { + const latest = items.reduce((max, item) => { + const updatedAt = new Date(item.updatedAt).getTime(); + return Number.isFinite(updatedAt) && (max === null || updatedAt > max) ? updatedAt : max; + }, null); + + return latest === null ? undefined : new Date(latest); +} + +export function countLiveCurrentItems(items: CurrentItem[], now = Date.now()): number { + return items.filter((item) => (now - new Date(item.time).getTime()) / 1000 <= 30).length; +} diff --git a/src/features/current/useCurrentEvents.ts b/src/features/current/useCurrentEvents.ts new file mode 100644 index 0000000..0327b9a --- /dev/null +++ b/src/features/current/useCurrentEvents.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getCurrentEventsUrl } from '../../entities/current/api'; +import type { CurrentEvent, CurrentResponse } from '../../entities/current/types'; + +export function useCurrentEvents(edgeId: string): boolean { + const queryClient = useQueryClient(); + const [connected, setConnected] = useState(false); + + useEffect(() => { + if (!edgeId || typeof EventSource === 'undefined') { + setConnected(false); + return undefined; + } + + const eventSource = new EventSource(getCurrentEventsUrl(edgeId)); + + eventSource.onopen = () => setConnected(true); + eventSource.onerror = () => setConnected(false); + eventSource.onmessage = (message) => { + const event = JSON.parse(message.data) as CurrentEvent; + queryClient.setQueryData(['current', edgeId], event); + }; + + return () => { + eventSource.close(); + setConnected(false); + }; + }, [edgeId, queryClient]); + + return connected; +} diff --git a/src/features/current/useCurrentQuery.ts b/src/features/current/useCurrentQuery.ts new file mode 100644 index 0000000..bb13c02 --- /dev/null +++ b/src/features/current/useCurrentQuery.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCurrent } from '../../entities/current/api'; + +export function useCurrentQuery(edgeId: string, eventsConnected: boolean) { + return useQuery({ + queryKey: ['current', edgeId], + queryFn: () => getCurrent(edgeId), + enabled: Boolean(edgeId), + refetchInterval: eventsConnected ? false : 1_000, + }); +} diff --git a/src/features/edge-detail/EdgeDetailPage.tsx b/src/features/edge-detail/EdgeDetailPage.tsx new file mode 100644 index 0000000..7a156f6 --- /dev/null +++ b/src/features/edge-detail/EdgeDetailPage.tsx @@ -0,0 +1,137 @@ +import { useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useAuth } from '../../auth/authContext'; +import { + countLiveCurrentItems, + createCurrentTagLabels, + filterCurrentItems, + getLatestCurrentUpdatedAt, +} from '../current/model'; +import { useCurrentEvents } from '../current/useCurrentEvents'; +import { useCurrentQuery } from '../current/useCurrentQuery'; +import { createRange } from '../history/dateRange'; +import { useHistoryQuery } from '../history/useHistoryQuery'; +import { ArchiveView } from './components/ArchiveView'; +import { EdgeSidebar } from './components/EdgeSidebar'; +import { EdgeTopbar } from './components/EdgeTopbar'; +import { EquipmentView } from './components/EquipmentView'; +import { IndicatorsView } from './components/IndicatorsView'; +import { OverviewView } from './components/OverviewView'; +import type { DetailView } from './types'; + +type EdgeDetailPageProps = { + view: DetailView; +}; + +/** Собирает раздел выбранной буровой из вкладок текущих данных, архива и оборудования. */ +export function EdgeDetailPage({ view }: EdgeDetailPageProps) { + const navigate = useNavigate(); + const auth = useAuth(); + const { edgeId = '' } = useParams(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [search, setSearch] = useState(''); + const [range, setRange] = useState(() => createRange(24)); + const [selectedTags, setSelectedTags] = useState([]); + + const edgePath = `/edges/${encodeURIComponent(edgeId)}`; + const currentEventsConnected = useCurrentEvents(edgeId); + const current = useCurrentQuery(edgeId, currentEventsConnected); + const currentItems = useMemo(() => current.data?.items ?? [], [current.data?.items]); + + const tagLabels = useMemo(() => createCurrentTagLabels(currentItems), [currentItems]); + const getTagLabel = (tag: string) => tagLabels[tag] ?? tag; + const visibleItems = useMemo(() => filterCurrentItems(currentItems, search), [currentItems, search]); + const latestUpdatedAt = useMemo(() => getLatestCurrentUpdatedAt(currentItems), [currentItems]); + const liveCount = countLiveCurrentItems(currentItems); + const { query: history, granularity: historyGranularity } = useHistoryQuery(edgeId, selectedTags[0], range); + + const toggleTag = (tag: string) => { + setSelectedTags((prev) => (prev.includes(tag) ? [] : [tag])); + }; + + const selectFirstTags = () => { + setSelectedTags(visibleItems[0] ? [visibleItems[0].tag] : []); + }; + + const selectVisibleTags = () => { + setSelectedTags(visibleItems[0] ? [visibleItems[0].tag] : []); + }; + + const clearVisibleTags = () => { + const visibleTags = new Set(visibleItems.map((item) => item.tag)); + setSelectedTags((prev) => prev.filter((tag) => !visibleTags.has(tag))); + }; + + return ( +
+ setSidebarCollapsed((collapsed) => !collapsed)} + /> + +
+ navigate('/edges')} + onLogout={() => void auth.logout()} + onRefresh={() => void current.refetch()} + /> + + {view === 'overview' ? ( + navigate(`${edgePath}/archive`)} + onOpenIndicators={() => navigate(`${edgePath}/indicators`)} + onOpenEquipment={() => navigate(`${edgePath}/equipment`)} + /> + ) : null} + + {view === 'archive' ? ( + 0} + historyGranulate={history.data?.granulate ?? historyGranularity.granulate} + historyAxis={historyGranularity} + range={range} + onSearchChange={setSearch} + onRangeChange={setRange} + onToggleTag={toggleTag} + onSelectFirstTags={selectFirstTags} + onSelectVisibleTags={selectVisibleTags} + onClearVisibleTags={clearVisibleTags} + onClearTags={() => setSelectedTags([])} + getTagLabel={getTagLabel} + tagLabels={tagLabels} + /> + ) : null} + + {view === 'indicators' ? ( + + ) : null} + + {view === 'equipment' ? : null} +
+
+ ); +} diff --git a/src/features/edge-detail/components/ArchiveView.tsx b/src/features/edge-detail/components/ArchiveView.tsx new file mode 100644 index 0000000..79a534d --- /dev/null +++ b/src/features/edge-detail/components/ArchiveView.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { CalendarClock, ChevronDown, DatabaseZap, Search } from 'lucide-react'; +import type { CurrentItem } from '../../../entities/current/types'; +import type { HistoryResponse } from '../../../entities/history/types'; +import { HistoryChart } from '../../../features/history-chart/HistoryChart'; +import { RANGE_PRESETS, createRange, type DateRangeState } from '../../../features/history/dateRange'; +import { toIsoFromInput } from '../../../utils/format'; +import type { HistoryGranularity } from '../../../utils/historyGranularity'; + +type ArchiveViewProps = { + getTagLabel: (tag: string) => string; + history?: HistoryResponse; + historyAxis: HistoryGranularity; + historyGranulate?: string; + historyLoading: boolean; + items: CurrentItem[]; + range: DateRangeState; + search: string; + selectedTags: string[]; + tagLabels: Record; + onClearTags: () => void; + onClearVisibleTags: () => void; + onRangeChange: (value: DateRangeState) => void; + onSearchChange: (value: string) => void; + onSelectFirstTags: () => void; + onSelectVisibleTags: () => void; + onToggleTag: (tag: string) => void; +}; + +export function ArchiveView({ + getTagLabel, + history, + historyAxis, + historyGranulate, + historyLoading, + items, + range, + search, + selectedTags, + tagLabels, + onClearTags, + onClearVisibleTags, + onRangeChange, + onSearchChange, + onSelectFirstTags, + onSelectVisibleTags, + onToggleTag, +}: ArchiveViewProps) { + const [selectorOpen, setSelectorOpen] = useState(false); + const selectedPreview = selectedTags.slice(0, 10); + const hiddenSelectedCount = Math.max(0, selectedTags.length - selectedPreview.length); + + return ( +
+
+
+ + + История + +

График параметров

+
+
+ + cloud-v3 · {historyGranulate ?? 'ожидание'} +
+
+ +
+ + + {selectorOpen ? ( +
+
+ +
+ + + + +
+
+ +
+ {selectedPreview.length ? ( + selectedPreview.map((tag) => {getTagLabel(tag)}) + ) : ( + Не выбрано + )} + {hiddenSelectedCount > 0 ? +{hiddenSelectedCount} : null} +
+ +
+ {items.map((item) => { + const selected = selectedTags.includes(item.tag); + const label = getTagLabel(item.tag); + return ( + + ); + })} +
+
+ ) : null} +
+ +
+
+
+ {RANGE_PRESETS.map((preset) => ( + + ))} +
+ + +
+ + {selectedTags.length ? ( + + ) : ( +
Разверните список показателей и выберите серию для графика
+ )} +
+
+ ); +} diff --git a/src/features/edge-detail/components/EdgeSidebar.tsx b/src/features/edge-detail/components/EdgeSidebar.tsx new file mode 100644 index 0000000..c529085 --- /dev/null +++ b/src/features/edge-detail/components/EdgeSidebar.tsx @@ -0,0 +1,70 @@ +import { Activity, BarChart3, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Wrench } from 'lucide-react'; +import type { DetailView } from '../types'; + +type EdgeSidebarProps = { + collapsed: boolean; + edgePath: string; + view: DetailView; + onNavigate: (path: string) => void; + onToggleCollapsed: () => void; +}; + +export function EdgeSidebar({ collapsed, edgePath, view, onNavigate, onToggleCollapsed }: EdgeSidebarProps) { + return ( + + ); +} diff --git a/src/features/edge-detail/components/EdgeTopbar.tsx b/src/features/edge-detail/components/EdgeTopbar.tsx new file mode 100644 index 0000000..5c5a2ff --- /dev/null +++ b/src/features/edge-detail/components/EdgeTopbar.tsx @@ -0,0 +1,46 @@ +import { LogOut, Menu, RefreshCw } from 'lucide-react'; + +type EdgeTopbarProps = { + authEnabled: boolean; + currentEventsConnected: boolean; + edgeId: string; + onBack: () => void; + onLogout: () => void; + onRefresh: () => void; +}; + +export function EdgeTopbar({ + authEnabled, + currentEventsConnected, + edgeId, + onBack, + onLogout, + onRefresh, +}: EdgeTopbarProps) { + return ( +
+
+ Операторская панель +

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

+ + {currentEventsConnected ? 'SSE live' : 'polling'} + +
+
+ + + {authEnabled ? ( + + ) : null} +
+
+ ); +} diff --git a/src/features/edge-detail/components/EquipmentView.tsx b/src/features/edge-detail/components/EquipmentView.tsx new file mode 100644 index 0000000..862cd20 --- /dev/null +++ b/src/features/edge-detail/components/EquipmentView.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; +import { ToirLightIframe } from '../../../components/ToirLightIframe'; + +const ACTIVE_EQUIPMENT_PATH = + '/equipment?filter=%7B%22status%22%3A%5B%22Active%22%5D%7D&displayedFilters=%7B%22status%22%3Atrue%7D'; + +export function EquipmentView() { + const [iframeLoaded, setIframeLoaded] = useState(false); + + return ( +
+
+ {!iframeLoaded ? ( +
+ + Загрузка интерфейса управления оборудованием... +
+ ) : null} + + setIframeLoaded(true)} + /> +
+
+ ); +} diff --git a/src/features/edge-detail/components/IndicatorsView.tsx b/src/features/edge-detail/components/IndicatorsView.tsx new file mode 100644 index 0000000..282002c --- /dev/null +++ b/src/features/edge-detail/components/IndicatorsView.tsx @@ -0,0 +1,133 @@ +import { useMemo, useState } from 'react'; +import { Search } from 'lucide-react'; +import type { CurrentItem } from '../../../entities/current/types'; +import { MetricCard } from '../../../components/MetricCard'; +import { formatNumber } from '../../../utils/format'; +import { getMetricStatus } from '../../../utils/metricStatus'; + +type IndicatorsViewProps = { + error: unknown; + getTagLabel: (tag: string) => string; + isError: boolean; + items: CurrentItem[]; + search: string; + selectedTags: string[]; + onSearchChange: (value: string) => void; + onToggleTag: (tag: string) => void; +}; + +export function IndicatorsView({ + error, + getTagLabel, + isError, + items, + search, + selectedTags, + onSearchChange, + onToggleTag, +}: IndicatorsViewProps) { + const [displayMode, setDisplayMode] = useState<'overview' | 'cards'>('overview'); + const itemStatuses = useMemo(() => { + const now = Date.now(); + return items.map((item) => ({ item, statusInfo: getMetricStatus(item, now) })); + }, [items]); + const statusCounts = itemStatuses.reduce( + (counts, { statusInfo }) => { + counts[statusInfo.status] += 1; + return counts; + }, + { normal: 0, warning: 0, critical: 0 }, + ); + + return ( +
+
+
+ + + Показатели + +

Текущие значения

+
+
+
+ + +
+ +
+
+ +
+
+ Всего + {items.length} +
+
+ В норме + {statusCounts.normal} +
+
+ Предупреждение + {statusCounts.warning} +
+
+ Критично + {statusCounts.critical} +
+
+ На графике + {selectedTags.length} +
+
+ + {isError ? ( +
Не удалось загрузить текущие значения: {String(error)}
+ ) : displayMode === 'overview' ? ( +
+ {itemStatuses.map(({ item, statusInfo }) => ( + + ))} +
+ ) : ( +
+ {itemStatuses.map(({ item, statusInfo }) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/edge-detail/components/OverviewView.tsx b/src/features/edge-detail/components/OverviewView.tsx new file mode 100644 index 0000000..7ca83aa --- /dev/null +++ b/src/features/edge-detail/components/OverviewView.tsx @@ -0,0 +1,72 @@ +import { Activity, BarChart3, CalendarClock, Wrench } from 'lucide-react'; +import { formatDateTime } from '../../../utils/format'; + +type OverviewViewProps = { + edgeId: string; + latestUpdatedAt?: Date; + liveCount: number; + selectedCount: number; + totalTags: number; + onOpenArchive: () => void; + onOpenEquipment: () => void; + onOpenIndicators: () => void; +}; + +export function OverviewView({ + edgeId, + latestUpdatedAt, + liveCount, + selectedCount, + totalTags, + onOpenArchive, + onOpenEquipment, + onOpenIndicators, +}: OverviewViewProps) { + return ( +
+
+
+ Всего показателей + {totalTags} +
+
+ Live + {liveCount} +
+
+ Выбрано для графика + {selectedCount} +
+
+ Последнее обновление + {latestUpdatedAt ? formatDateTime(latestUpdatedAt) : '-'} +
+
+ +
+
+ Разделы +

{edgeId}

+
+
+ + + + +
+
+
+ ); +} diff --git a/src/features/edge-detail/types.ts b/src/features/edge-detail/types.ts new file mode 100644 index 0000000..5b5c97d --- /dev/null +++ b/src/features/edge-detail/types.ts @@ -0,0 +1 @@ +export type DetailView = 'overview' | 'archive' | 'indicators' | 'equipment'; diff --git a/src/features/edges-dashboard/EdgesDashboard.tsx b/src/features/edges-dashboard/EdgesDashboard.tsx new file mode 100644 index 0000000..1c7df02 --- /dev/null +++ b/src/features/edges-dashboard/EdgesDashboard.tsx @@ -0,0 +1,162 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ArrowRight, Clock3, Gauge, LogOut, RefreshCw, Search, ShieldAlert, Wrench } from 'lucide-react'; +import edgeImage from '../../assets/edge.png'; +import { getEdges } from '../../entities/edge/api'; +import type { EdgeItem } from '../../entities/edge/types'; +import { useAuth } from '../../auth/authContext'; + +type EdgesDashboardProps = { + onOpenEdge: (edgeId: string) => void; + onOpenEquipment: (edgeId: string) => void; +}; + +function getEdgeTitle(edge: EdgeItem): string { + return edge.name && edge.name !== edge.id ? edge.name : edge.id; +} + +function filterEdges(edges: EdgeItem[], search: string): EdgeItem[] { + const query = search.trim().toLowerCase(); + return query ? edges.filter((edge) => `${edge.id} ${edge.name}`.toLowerCase().includes(query)) : edges; +} + +/** Отображает список буровых без расчетов по текущим значениям. */ +export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardProps) { + const [search, setSearch] = useState(''); + const auth = useAuth(); + const edges = useQuery({ + queryKey: ['edge'], + queryFn: getEdges, + refetchInterval: false, + }); + + const filteredEdges = useMemo(() => filterEdges(edges.data?.items ?? [], search), [edges.data?.items, search]); + + return ( +
+
+
+ Drill Cloud v3 +

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

+
+
+ + + {auth.enabled ? ( + + ) : null} +
+
+ +
+ + +
+ + {edges.isError ? ( +
Не удалось загрузить список буровых: {String(edges.error)}
+ ) : ( +
+ {filteredEdges.map((edge) => ( + + ))} +
+ )} + + {!edges.isPending && !filteredEdges.length && !edges.isError ? ( +
В cloud-v3 пока нет буровых
+ ) : null} +
+ ); +} + +function StatBlock({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: 'neutral' | 'success' | 'danger' | 'warning' | 'accent'; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function EdgeCard({ + edge, + onOpenEdge, + onOpenEquipment, +}: { + edge: EdgeItem; + onOpenEdge: (edgeId: string) => void; + onOpenEquipment: (edgeId: string) => void; +}) { + const title = getEdgeTitle(edge); + + return ( +
+
+
+ +
+
+

{title}

+ {edge.id} +
+
+ +
+ + + + +
+ +
+ Техническое обслуживание +
+ + + + + +
+
+ + +
+ ); +} diff --git a/src/features/electrical-schematics/ElectricalNode.tsx b/src/features/electrical-schematics/ElectricalNode.tsx new file mode 100644 index 0000000..3b51f56 --- /dev/null +++ b/src/features/electrical-schematics/ElectricalNode.tsx @@ -0,0 +1,78 @@ +import { type CSSProperties } from 'react'; +import { Activity, AlertTriangle, PlugZap } from 'lucide-react'; +import type { DiagramNode } from '../../entities/diagram/types'; +import { formatNumber } from '../../utils/format'; +import { getNodeSize } from './geometry'; +import { getLiveValue } from './model'; +import type { LiveValue } from './types'; + +function getNodeTitle(node: DiagramNode, value?: LiveValue) { + if (node.kind === 'tagWidget') { + return node.label || value?.name || node.tagId; + } + + return String(node.data?.title || node.data?.text || node.decorationType); +} + +type ElectricalNodeProps = { + liveValues: Map; + node: DiagramNode; + ownerEdgeId: string; +}; + +export function ElectricalNode({ liveValues, node, ownerEdgeId }: ElectricalNodeProps) { + 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/features/electrical-schematics/ElectricalSchematicsPage.tsx b/src/features/electrical-schematics/ElectricalSchematicsPage.tsx new file mode 100644 index 0000000..f0d97ce --- /dev/null +++ b/src/features/electrical-schematics/ElectricalSchematicsPage.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { CircuitBoard, DatabaseZap, RefreshCw } from 'lucide-react'; +import { getPublishedDiagramPage, listDiagramPages } from '../../entities/diagram/api'; +import { formatDateTime } from '../../utils/format'; +import { ElectricalNode } from './ElectricalNode'; +import { getCanvasBounds, getEdgePoints, pointsToPolyline } from './geometry'; +import { collectBindingTags, createLiveGroups, pickDefaultPage } from './model'; +import { useElectricalLiveValues } from './useElectricalLiveValues'; + +type ElectricalSchematicsPageProps = { + edgeId: string; +}; + +/** Отображает опубликованную электросхему и подставляет live-значения через SSE cloud-v3. */ +export function ElectricalSchematicsPage({ edgeId }: ElectricalSchematicsPageProps) { + const [selectedPageKey, setSelectedPageKey] = useState(''); + + const pages = useQuery({ + queryKey: ['diagram-pages', edgeId], + queryFn: () => listDiagramPages(edgeId), + enabled: Boolean(edgeId), + }); + + const publishedPages = useMemo(() => pages.data?.filter((page) => page.publishedRevision) ?? [], [pages.data]); + const selectedPage = useMemo( + () => publishedPages.find((page) => page.pageKey === selectedPageKey) ?? pickDefaultPage(publishedPages, edgeId), + [edgeId, publishedPages, selectedPageKey], + ); + + useEffect(() => { + if (selectedPage && selectedPage.pageKey !== selectedPageKey) { + setSelectedPageKey(selectedPage.pageKey); + } + }, [selectedPage, selectedPageKey]); + + const page = useQuery({ + queryKey: ['published-diagram-page', selectedPage?.pageKey], + queryFn: () => getPublishedDiagramPage(selectedPage!.pageKey), + enabled: Boolean(selectedPage?.pageKey), + }); + + const diagramDocument = page.data?.document; + const bindingTags = useMemo(() => (diagramDocument ? collectBindingTags(diagramDocument) : []), [diagramDocument]); + const liveGroups = useMemo(() => createLiveGroups(bindingTags), [bindingTags]); + const nodeMap = useMemo(() => new Map((diagramDocument?.nodes ?? []).map((node) => [node.id, node])), [diagramDocument?.nodes]); + const canvasBounds = useMemo(() => getCanvasBounds(diagramDocument), [diagramDocument]); + const { connectedSources, liveValues } = useElectricalLiveValues(liveGroups, selectedPage?.pageKey); + const liveTagCount = liveValues.size ? Array.from(liveValues.keys()).filter((key) => key.startsWith('tag:')).length : 0; + const connected = connectedSources.size > 0; + + return ( +
+
+
+ + + Электросхемы + +

{page.data?.title || diagramDocument?.title || selectedPage?.pageKey || edgeId}

+
+
+ {publishedPages.length > 1 ? ( + + ) : null} +
+ + {connected ? `SSE · ${connectedSources.size}` : liveGroups.length ? 'подключение SSE' : 'нет привязок'} +
+ +
+
+ + {pages.isError ? ( +
Не удалось загрузить список опубликованных схем: {String(pages.error)}
+ ) : null} + + {!pages.isPending && !publishedPages.length && !pages.isError ? ( +
Для {edgeId} пока нет опубликованных схем
+ ) : null} + + {page.isError ? ( +
Не удалось загрузить опубликованную схему: {String(page.error)}
+ ) : null} + + {diagramDocument ? ( +
+
+
+ {diagramDocument.background?.url ? ( + + ) : null} + + + {(diagramDocument.edges ?? []).map((edge) => { + const points = getEdgePoints(edge, nodeMap); + if (points.length < 2) { + return null; + } + + return ( + + ); + })} + + + {diagramDocument.nodes.map((node) => ( + + ))} +
+
+ + +
+ ) : page.isPending || pages.isPending ? ( +
Загрузка опубликованной схемы...
+ ) : null} +
+ ); +} diff --git a/src/features/electrical-schematics/geometry.ts b/src/features/electrical-schematics/geometry.ts new file mode 100644 index 0000000..a8f58c0 --- /dev/null +++ b/src/features/electrical-schematics/geometry.ts @@ -0,0 +1,42 @@ +import type { DiagramDocument, DiagramEdge, DiagramNode, DiagramPoint } from '../../entities/diagram/types'; + +export function getCanvasBounds(document?: DiagramDocument) { + const nodes = document?.nodes ?? []; + const maxX = Math.max(...nodes.map((node) => node.position.x + getNodeSize(node).width), 960); + const maxY = Math.max(...nodes.map((node) => node.position.y + getNodeSize(node).height), 560); + + return { + width: Math.ceil(maxX + 80), + height: Math.ceil(maxY + 80), + }; +} + +export function getNodeSize(node: DiagramNode) { + if (node.kind === 'decoration') { + return node.size; + } + + return node.size ?? { width: 190, height: 82 }; +} + +export function getNodeCenter(node: DiagramNode): DiagramPoint { + const size = getNodeSize(node); + return { + x: node.position.x + size.width / 2, + y: node.position.y + size.height / 2, + }; +} + +export function getEdgePoints(edge: DiagramEdge, nodeMap: Map): DiagramPoint[] { + const source = nodeMap.get(edge.sourceNodeId); + const target = nodeMap.get(edge.targetNodeId); + if (!source || !target) { + return []; + } + + return [getNodeCenter(source), ...(edge.waypoints ?? []), getNodeCenter(target)]; +} + +export function pointsToPolyline(points: DiagramPoint[]) { + return points.map((point) => `${point.x},${point.y}`).join(' '); +} diff --git a/src/features/electrical-schematics/model.ts b/src/features/electrical-schematics/model.ts new file mode 100644 index 0000000..9a062c5 --- /dev/null +++ b/src/features/electrical-schematics/model.ts @@ -0,0 +1,90 @@ +import type { DiagramDocument, DiagramPageSummary } from '../../entities/diagram/types'; +import type { BindingTag, LiveGroup, LiveValue } from './types'; + +const DEFAULT_PAGE_PREFIXES = ['ELECTRICAL', 'MAIN', 'DIRECT']; + +export function pickDefaultPage(pages: DiagramPageSummary[], edgeId: string): DiagramPageSummary | undefined { + const published = pages.filter((page) => page.publishedRevision); + const byPriority = [...published].sort((a, b) => { + const aRank = getPageRank(a.pageKey, edgeId); + const bRank = getPageRank(b.pageKey, edgeId); + return aRank - bRank || a.pageKey.localeCompare(b.pageKey); + }); + + return byPriority[0]; +} + +function getPageRank(pageKey: string, edgeId: string): number { + const exactKeys = DEFAULT_PAGE_PREFIXES.map((prefix) => (prefix === 'DIRECT' ? edgeId : `${prefix}_${edgeId}`)); + const exactIndex = exactKeys.indexOf(pageKey); + if (exactIndex >= 0) { + return exactIndex; + } + + return pageKey.includes(edgeId) ? 10 : 20; +} + +export function collectBindingTags(document: DiagramDocument): BindingTag[] { + const tags: BindingTag[] = []; + + for (const node of document.nodes) { + if (node.kind === 'tagWidget') { + tags.push({ + tagId: node.tagId, + edgeId: node.edgeId || document.ownerEdgeId, + sourceKey: `edge:${node.edgeId || document.ownerEdgeId}`, + }); + continue; + } + + const bindings = node.bindings; + if (!bindings) { + continue; + } + + const edgeId = bindings.sseEdgeId || bindings.edgeId || document.ownerEdgeId; + const sourceKey = bindings.sseUrl || `edge:${edgeId}`; + + [bindings.stateTagId, bindings.alarmTagId].filter(Boolean).forEach((tagId) => { + tags.push({ + tagId: tagId as string, + edgeId, + sourceKey, + sseUrl: bindings.sseUrl, + }); + }); + } + + const unique = new Map(); + tags.forEach((tag) => unique.set(`${tag.sourceKey}:${tag.edgeId}:${tag.tagId}`, tag)); + return Array.from(unique.values()); +} + +export function createLiveGroups(tags: BindingTag[]): LiveGroup[] { + const groups = new Map(); + + tags.forEach((tag) => { + const group = groups.get(tag.sourceKey) ?? { + sourceKey: tag.sourceKey, + edgeId: tag.edgeId, + sseUrl: tag.sseUrl, + tags: [], + }; + + if (!group.tags.includes(tag.tagId)) { + group.tags.push(tag.tagId); + } + + groups.set(tag.sourceKey, group); + }); + + return Array.from(groups.values()); +} + +export function getLiveValue(values: Map, edgeId: string, tagId?: string): LiveValue | undefined { + if (!tagId) { + return undefined; + } + + return values.get(`${edgeId}:${tagId}`) ?? values.get(`tag:${tagId}`); +} diff --git a/src/features/electrical-schematics/types.ts b/src/features/electrical-schematics/types.ts new file mode 100644 index 0000000..e9993c0 --- /dev/null +++ b/src/features/electrical-schematics/types.ts @@ -0,0 +1,19 @@ +import type { CurrentItem } from '../../entities/current/types'; + +export type LiveValue = CurrentItem & { + sourceKey: string; +}; + +export type BindingTag = { + tagId: string; + edgeId: string; + sourceKey: string; + sseUrl?: string; +}; + +export type LiveGroup = { + sourceKey: string; + edgeId: string; + sseUrl?: string; + tags: string[]; +}; diff --git a/src/features/electrical-schematics/useElectricalLiveValues.ts b/src/features/electrical-schematics/useElectricalLiveValues.ts new file mode 100644 index 0000000..b64558b --- /dev/null +++ b/src/features/electrical-schematics/useElectricalLiveValues.ts @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { getCurrent, getCurrentEventsUrl } from '../../entities/current/api'; +import type { CurrentEvent, CurrentResponse } from '../../entities/current/types'; +import type { LiveGroup, LiveValue } from './types'; + +function buildCustomSseUrl(sseUrl: string, tags: string[]): string { + const url = new URL(sseUrl, window.location.origin); + if (tags.length && !url.searchParams.has('tags')) { + tags.forEach((tag) => url.searchParams.append('tags', tag)); + } + + return url.toString(); +} + +export function useElectricalLiveValues(liveGroups: LiveGroup[], resetKey?: string) { + const [liveValues, setLiveValues] = useState(() => new Map()); + const [connectedSources, setConnectedSources] = useState>(new Set()); + + useEffect(() => { + setLiveValues(new Map()); + setConnectedSources(new Set()); + }, [resetKey]); + + useEffect(() => { + if (!liveGroups.length) { + return undefined; + } + + let closed = false; + const eventSources: EventSource[] = []; + + const mergeResponse = (response: CurrentResponse, sourceKey: string) => { + if (closed) { + return; + } + + setLiveValues((previous) => { + const next = new Map(previous); + response.items.forEach((item) => { + const value = { ...item, sourceKey }; + next.set(`${item.edge}:${item.tag}`, value); + next.set(`tag:${item.tag}`, value); + }); + return next; + }); + }; + + liveGroups.forEach((group) => { + if (!group.sseUrl) { + void getCurrent(group.edgeId, group.tags).then((response) => mergeResponse(response, group.sourceKey)); + } + + const url = group.sseUrl ? buildCustomSseUrl(group.sseUrl, group.tags) : getCurrentEventsUrl(group.edgeId, group.tags); + const eventSource = new EventSource(url); + + eventSource.onopen = () => { + setConnectedSources((previous) => new Set(previous).add(group.sourceKey)); + }; + + eventSource.onerror = () => { + setConnectedSources((previous) => { + const next = new Set(previous); + next.delete(group.sourceKey); + return next; + }); + }; + + eventSource.onmessage = (message) => { + const event = JSON.parse(message.data) as CurrentEvent; + mergeResponse(event, group.sourceKey); + }; + + eventSources.push(eventSource); + }); + + return () => { + closed = true; + eventSources.forEach((eventSource) => eventSource.close()); + }; + }, [liveGroups]); + + return { connectedSources, liveValues }; +} diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx new file mode 100644 index 0000000..418f004 --- /dev/null +++ b/src/features/history-chart/HistoryChart.tsx @@ -0,0 +1,92 @@ +import { LineChart, ScatterChart } from 'echarts/charts'; +import { + DataZoomComponent, + GridComponent, + LegendComponent, + TooltipComponent, +} from 'echarts/components'; +import * as echarts from 'echarts/core'; +import { CanvasRenderer } from 'echarts/renderers'; +import ReactEChartsCore from 'echarts-for-react/esm/core.js'; +import { useMemo, useRef } from 'react'; +import type { HistoryResponse } from '../../entities/history/types'; +import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; +import type { DataZoomEventBatch, DataZoomState } from './chartTypes'; +import { createHistoryChartOptions } from './historyChartOptions'; + +echarts.use([ + LineChart, + ScatterChart, + GridComponent, + LegendComponent, + TooltipComponent, + DataZoomComponent, + CanvasRenderer, +]); + +type HistoryChartProps = { + data?: HistoryResponse; + loading: boolean; + from?: string; + to?: string; + tickIntervalMs?: number; + labelFormat?: HistoryAxisLabelFormat; + tagLabels?: Record; +}; + +/** Рисует avg-линию, min/max-точки и вертикальный диапазон для агрегированной истории. */ +export function HistoryChart({ + data, + loading, + from, + to, + tickIntervalMs, + labelFormat, + tagLabels = {}, +}: HistoryChartProps) { + const dataZoomRef = useRef(null); + const legendData = useMemo( + () => data?.series.map((series) => tagLabels[series.tag] ?? series.tag) ?? [], + [data?.series, tagLabels], + ); + + const option = useMemo( + () => + createHistoryChartOptions({ + data, + dataZoomState: dataZoomRef.current ?? {}, + from, + to, + labelFormat, + legendData, + tagLabels, + tickIntervalMs, + }), + [data, from, labelFormat, legendData, tagLabels, tickIntervalMs, to], + ); + + const onChartEvents = useMemo( + () => ({ + datazoom: (event: DataZoomEventBatch) => { + const state = event.batch?.[0] ?? event; + dataZoomRef.current = { + start: state.start, + end: state.end, + startValue: state.startValue, + endValue: state.endValue, + }; + }, + }), + [], + ); + + if (loading) { + return
Загрузка графика...
; + } + + if (!data?.series.length) { + return
Нет данных для выбранного диапазона
; + } + + return ; +} diff --git a/src/features/history-chart/chartTypes.ts b/src/features/history-chart/chartTypes.ts new file mode 100644 index 0000000..dd02491 --- /dev/null +++ b/src/features/history-chart/chartTypes.ts @@ -0,0 +1,18 @@ +export type AvgPointValue = [time: number, avg: number, min: number, max: number, count: number]; + +export type TooltipParam = { + marker?: string; + seriesName?: string; + value?: unknown; +}; + +export type DataZoomState = { + start?: number; + end?: number; + startValue?: number; + endValue?: number; +}; + +export type DataZoomEventBatch = DataZoomState & { + batch?: DataZoomState[]; +}; diff --git a/src/features/history-chart/historyChartFormat.ts b/src/features/history-chart/historyChartFormat.ts new file mode 100644 index 0000000..a86bfa0 --- /dev/null +++ b/src/features/history-chart/historyChartFormat.ts @@ -0,0 +1,102 @@ +import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; +import type { AvgPointValue, TooltipParam } from './chartTypes'; + +export function formatAxisDate(value: number, labelFormat: HistoryAxisLabelFormat = 'time-minutes'): string { + const date = new Date(value); + + if (labelFormat === 'time-seconds') { + return new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(date); + } + + if (labelFormat === 'time-minutes') { + return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date); + } + + if (labelFormat === 'hour-zero') { + return `${new Intl.DateTimeFormat('ru-RU', { hour: '2-digit' }).format(date)}:00`; + } + + if (labelFormat === 'day-hour') { + const day = new Intl.DateTimeFormat('ru-RU', { day: '2-digit' }).format(date); + const hour = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit' }).format(date); + return `${day} ${hour}:00`; + } + + if (labelFormat === 'weekday-day') { + return new Intl.DateTimeFormat('ru-RU', { weekday: 'short', day: '2-digit' }).format(date); + } + + if (labelFormat === 'day-month') { + return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date); + } + + if (labelFormat === 'day-month-name') { + return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'short' }).format(date); + } + + if (labelFormat === 'month-name') { + return new Intl.DateTimeFormat('ru-RU', { month: 'short' }).format(date); + } + + if (labelFormat === 'month-year') { + return new Intl.DateTimeFormat('ru-RU', { month: 'short', year: '2-digit' }).format(date); + } + + if (labelFormat === 'quarter-year') { + const quarter = Math.floor(date.getMonth() / 3) + 1; + return `Кв${quarter} ${String(date.getFullYear()).slice(-2)}`; + } + + return new Intl.DateTimeFormat('ru-RU', { year: 'numeric' }).format(date); +} + +function formatChartValue(value: number): string { + return new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: Math.abs(value) >= 100 ? 2 : 3, + }).format(value); +} + +function isAvgPointValue(value: unknown): value is AvgPointValue { + return Array.isArray(value) && value.length >= 5 && value.every((item) => typeof item === 'number'); +} + +/** Собирает tooltip с min/avg/max для основной avg-серии. */ +export 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[1])} · max ${formatChartValue(value[3])}`, + value[4] > 1 ? ` · ${value[4]} точек` : '', + '', + '
', + ].join(''); + }); + + return [`
${header}
`, ...rows, '
'].join(''); +} diff --git a/src/features/history-chart/historyChartOptions.ts b/src/features/history-chart/historyChartOptions.ts new file mode 100644 index 0000000..3b5cf2e --- /dev/null +++ b/src/features/history-chart/historyChartOptions.ts @@ -0,0 +1,110 @@ +import type { EChartsOption } from 'echarts'; +import type { HistoryResponse } from '../../entities/history/types'; +import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; +import type { DataZoomState } from './chartTypes'; +import { formatAxisDate, formatTooltip } from './historyChartFormat'; +import { createSeriesOptions, SERIES_COLORS } from './historyChartSeries'; + +export type HistoryChartOptionsInput = { + data?: HistoryResponse; + dataZoomState: DataZoomState; + from?: string; + to?: string; + labelFormat?: HistoryAxisLabelFormat; + legendData: string[]; + tagLabels: Record; + tickIntervalMs?: number; +}; + +export function createHistoryChartOptions({ + data, + dataZoomState, + from, + to, + labelFormat, + legendData, + tagLabels, + tickIntervalMs, +}: HistoryChartOptionsInput): EChartsOption { + const xMin = from ? new Date(from).getTime() : undefined; + const xMax = to ? new Date(to).getTime() : undefined; + + return { + color: SERIES_COLORS, + animation: false, + backgroundColor: 'transparent', + textStyle: { + color: '#cbd5e1', + fontFamily: 'Inter, Segoe UI, sans-serif', + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(10, 13, 18, 0.96)', + borderColor: 'rgba(212, 165, 116, 0.32)', + textStyle: { color: '#f8fafc' }, + formatter: formatTooltip, + }, + legend: { + type: 'scroll', + top: 0, + data: legendData, + textStyle: { color: '#cbd5e1' }, + }, + grid: { + top: 54, + left: 48, + right: 24, + bottom: 72, + }, + xAxis: { + type: 'time', + min: Number.isFinite(xMin) ? xMin : undefined, + max: Number.isFinite(xMax) ? xMax : undefined, + minInterval: tickIntervalMs, + maxInterval: tickIntervalMs, + axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } }, + axisLabel: { + color: '#94a3b8', + formatter: (value: number) => formatAxisDate(value, labelFormat), + }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + scale: true, + axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } }, + axisLabel: { color: '#94a3b8' }, + splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } }, + }, + dataZoom: [ + { + type: 'inside', + throttle: 80, + start: dataZoomState.start, + end: dataZoomState.end, + startValue: dataZoomState.startValue, + endValue: dataZoomState.endValue, + }, + { + type: 'slider', + bottom: 18, + height: 28, + borderColor: 'rgba(148, 163, 184, 0.24)', + fillerColor: 'rgba(91, 143, 249, 0.18)', + handleStyle: { + color: '#d4a574', + borderColor: '#e8c9a0', + }, + textStyle: { color: '#94a3b8' }, + start: dataZoomState.start, + end: dataZoomState.end, + startValue: dataZoomState.startValue, + endValue: dataZoomState.endValue, + }, + ], + series: + data?.series.flatMap((series, index) => + createSeriesOptions(series, index, tagLabels[series.tag] ?? series.tag), + ) ?? [], + }; +} diff --git a/src/features/history-chart/historyChartSeries.ts b/src/features/history-chart/historyChartSeries.ts new file mode 100644 index 0000000..2efed73 --- /dev/null +++ b/src/features/history-chart/historyChartSeries.ts @@ -0,0 +1,101 @@ +import type { SeriesOption } from 'echarts'; +import type { HistorySeries } from '../../entities/history/types'; +import type { AvgPointValue } from './chartTypes'; + +export const SERIES_COLORS = [ + '#5B8FF9', + '#5AD8A6', + '#F6BD16', + '#E8684A', + '#6DC8EC', + '#FF9D4D', + '#73D13D', + '#A7B3FF', +]; + +/** Создает avg-линию, вертикальные min/max-отрезки и точки экстремумов для одной серии. */ +export function createSeriesOptions(series: HistorySeries, index: number, label: string): SeriesOption[] { + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + const avgData: AvgPointValue[] = series.points.map((point) => [ + point.t, + point.avg, + point.min, + 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, + }, + ]; +} diff --git a/src/features/history/dateRange.ts b/src/features/history/dateRange.ts new file mode 100644 index 0000000..dcdb5c6 --- /dev/null +++ b/src/features/history/dateRange.ts @@ -0,0 +1,23 @@ +import { toInputDateTimeValue } from '../../utils/format'; + +export type DateRangeState = { + from: string; + to: string; +}; + +export const RANGE_PRESETS = [ + { id: '1h', label: '1 час', hours: 1 }, + { id: '6h', label: '6 часов', hours: 6 }, + { id: '24h', label: '24 часа', hours: 24 }, + { id: '7d', label: '7 дней', hours: 24 * 7 }, + { id: '30d', label: '30 дней', hours: 24 * 30 }, +] as const; + +export function createRange(hours: number): DateRangeState { + const to = new Date(); + const from = new Date(to.getTime() - hours * 60 * 60 * 1000); + return { + from: toInputDateTimeValue(from), + to: toInputDateTimeValue(to), + }; +} diff --git a/src/features/history/useHistoryQuery.ts b/src/features/history/useHistoryQuery.ts new file mode 100644 index 0000000..fd2b6a7 --- /dev/null +++ b/src/features/history/useHistoryQuery.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getHistory } from '../../entities/history/api'; +import { toIsoFromInput } from '../../utils/format'; +import { getHistoryGranularity } from '../../utils/historyGranularity'; +import type { DateRangeState } from './dateRange'; + +export function useHistoryQuery(edgeId: string, selectedTag: string | undefined, range: DateRangeState) { + const from = toIsoFromInput(range.from) as string; + const to = toIsoFromInput(range.to) as string; + const granularity = useMemo(() => getHistoryGranularity(from, to), [from, to]); + + const query = useQuery({ + queryKey: ['history', edgeId, selectedTag, from, to, granularity.granulate], + queryFn: () => + getHistory({ + edge: edgeId, + tag: selectedTag as string, + from, + to, + granulate: granularity.granulate, + }), + enabled: Boolean(edgeId && selectedTag), + refetchInterval: false, + }); + + return { query, from, to, granularity }; +} diff --git a/src/integrations/toir.ts b/src/integrations/toir.ts index 023bafa..e8369db 100644 --- a/src/integrations/toir.ts +++ b/src/integrations/toir.ts @@ -1,16 +1,16 @@ -const DEFAULT_TOIR_ORIGIN = 'https://toir-light.greact.ru'; +import { toirLightOrigin } from '../shared/config/env'; -/** Возвращает origin встроенного TOиР-приложения с безопасной нормализацией слеша. */ +/** Возвращает origin встроенного ТОиР-приложения из Vite env. */ export function getToirLightOrigin(): string { - return (import.meta.env.VITE_TOIR_LIGHT_ORIGIN ?? DEFAULT_TOIR_ORIGIN).replace(/\/$/, ''); + return toirLightOrigin; } -/** Определяет targetOrigin для postMessage в iframe TOиР. */ +/** Определяет targetOrigin для postMessage в iframe ТОиР. */ export function getToirLightPostMessageTarget(): string { return getToirLightOrigin(); } -/** Собирает полный URL iframe TOиР и передает тему через query-параметр. */ +/** Собирает полный URL iframe ТОиР и передает тему через query-параметр. */ export function buildToirLightUrl(path: string, theme = 'dark'): string { const origin = getToirLightOrigin(); const normalizedPath = path.startsWith('/') ? path : `/${path}`; diff --git a/src/main.tsx b/src/main.tsx index 506faba..0d09455 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,26 +1,13 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; -import { AuthProvider } from './auth/AuthProvider'; +import { AppProviders } from './app/providers'; import './styles/index.css'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1_000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - createRoot(document.getElementById('root')!).render( - - - - - + + + , ); diff --git a/src/pages/EdgeDetailPage.tsx b/src/pages/EdgeDetailPage.tsx index 9220190..c9d68e6 100644 --- a/src/pages/EdgeDetailPage.tsx +++ b/src/pages/EdgeDetailPage.tsx @@ -1,723 +1,2 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { - Activity, - BarChart3, - CalendarClock, - ChevronDown, - DatabaseZap, - Gauge, - LogOut, - Menu, - PanelLeftClose, - PanelLeftOpen, - RefreshCw, - Search, - Wrench, -} from 'lucide-react'; -import { useNavigate, useParams } from 'react-router-dom'; -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'; -import { ToirLightIframe } from '../components/ToirLightIframe'; -import { formatDateTime, formatNumber, toInputDateTimeValue, toIsoFromInput } from '../utils/format'; -import { getMetricStatus } from '../utils/metricStatus'; - -const RANGE_PRESETS = [ - { id: '1h', label: '1 час', hours: 1 }, - { id: '6h', label: '6 часов', hours: 6 }, - { id: '24h', label: '24 часа', hours: 24 }, - { id: '7d', label: '7 дней', hours: 24 * 7 }, - { id: '30d', label: '30 дней', hours: 24 * 30 }, -] as const; - -const ACTIVE_EQUIPMENT_PATH = - '/equipment?filter=%7B%22status%22%3A%5B%22Active%22%5D%7D&displayedFilters=%7B%22status%22%3Atrue%7D'; - -type DetailView = 'overview' | 'archive' | 'indicators' | 'equipment'; - -type EdgeDetailPageProps = { - view: DetailView; -}; - -type DateRangeState = { - from: string; - to: string; -}; - -/** Создает диапазон истории от текущего времени на заданное количество часов назад. */ -function createRange(hours: number): DateRangeState { - const to = new Date(); - const from = new Date(to.getTime() - hours * 60 * 60 * 1000); - return { - from: toInputDateTimeValue(from), - to: toInputDateTimeValue(to), - }; -} - -/** Возвращает русское имя тега из 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(); - const auth = useAuth(); - const { edgeId = '' } = useParams(); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [currentEventsConnected, setCurrentEventsConnected] = useState(false); - const [search, setSearch] = useState(''); - const [range, setRange] = useState(() => createRange(24)); - const [selectedTags, setSelectedTags] = useState([]); - - const edgePath = `/edges/${encodeURIComponent(edgeId)}`; - - const current = useQuery({ - queryKey: ['current', edgeId], - queryFn: () => getCurrent(edgeId), - enabled: Boolean(edgeId), - refetchInterval: currentEventsConnected ? false : 1_000, - }); - - useEffect(() => { - if (!edgeId || typeof EventSource === 'undefined') { - setCurrentEventsConnected(false); - return undefined; - } - - const eventSource = new EventSource(getCurrentEventsUrl(edgeId)); - - eventSource.onopen = () => setCurrentEventsConnected(true); - eventSource.onerror = () => setCurrentEventsConnected(false); - eventSource.onmessage = (message) => { - const event = JSON.parse(message.data) as CurrentEvent; - queryClient.setQueryData(['current', edgeId], event); - }; - - return () => { - eventSource.close(); - setCurrentEventsConnected(false); - }; - }, [edgeId, queryClient]); - - const tagLabels = useMemo(() => { - return Object.fromEntries((current.data?.items ?? []).map((item) => [item.tag, getItemLabel(item)])); - }, [current.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, - item.name, - item.comment, - item.tagGroup, - item.unitOfMeasurement, - ] - .filter(Boolean) - .join(' ') - .toLowerCase() - .includes(query), - ) - : items; - }, [current.data?.items, search]); - - const latestUpdatedAt = useMemo(() => { - const latest = (current.data?.items ?? []).reduce((max, item) => { - const updatedAt = new Date(item.updatedAt).getTime(); - return Number.isFinite(updatedAt) && (max === null || updatedAt > max) ? updatedAt : max; - }, null); - - 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 history = useQuery({ - queryKey: ['history', edgeId, selectedTags, range.from, range.to], - queryFn: () => - getHistory({ - edge: edgeId, - tags: selectedTags, - from: toIsoFromInput(range.from), - to: toIsoFromInput(range.to), - targetPoints: 1600, - }), - enabled: Boolean(edgeId && selectedTags.length), - refetchInterval: false, - }); - - /** Переключает выбранный тег для отображения на историческом графике. */ - const toggleTag = (tag: string) => { - setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((item) => item !== tag) : [...prev, tag])); - }; - - /** Быстро выбирает первые найденные показатели для первичного анализа графика. */ - const selectFirstTags = () => { - setSelectedTags(visibleItems.slice(0, 6).map((item) => item.tag)); - }; - - /** Добавляет все отфильтрованные показатели к текущему набору выбранных тегов. */ - const selectVisibleTags = () => { - setSelectedTags((prev) => Array.from(new Set([...prev, ...visibleItems.map((item) => item.tag)]))); - }; - - /** Снимает выбор только с тех тегов, которые сейчас видны после фильтра поиска. */ - const clearVisibleTags = () => { - const visibleTags = new Set(visibleItems.map((item) => item.tag)); - setSelectedTags((prev) => prev.filter((tag) => !visibleTags.has(tag))); - }; - - return ( -
- - -
-
-
- Операторская панель -

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

- - {currentEventsConnected ? 'SSE live' : 'polling'} - -
-
- - - {auth.enabled ? ( - - ) : null} -
-
- - {view === 'overview' ? ( - navigate(`${edgePath}/archive`)} - onOpenIndicators={() => navigate(`${edgePath}/indicators`)} - onOpenEquipment={() => navigate(`${edgePath}/equipment`)} - /> - ) : null} - - {view === 'archive' ? ( - 0} - historySource={history.data?.source} - range={range} - onSearchChange={setSearch} - onRangeChange={setRange} - onToggleTag={toggleTag} - onSelectFirstTags={selectFirstTags} - onSelectVisibleTags={selectVisibleTags} - onClearVisibleTags={clearVisibleTags} - onClearTags={() => setSelectedTags([])} - getTagLabel={getTagLabel} - tagLabels={tagLabels} - /> - ) : null} - - {view === 'indicators' ? ( - - ) : null} - - {view === 'equipment' ? : null} - - {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} - {/* {view === 'electrical' ? : null} */} -
-
- ); -} - -/** Показывает обзор выбранного edge и быстрые переходы в основные разделы. */ -function OverviewView({ - edgeId, - totalTags, - liveCount, - latestUpdatedAt, - selectedCount, - onOpenArchive, - onOpenIndicators, - onOpenEquipment, -}: { - edgeId: string; - totalTags: number; - liveCount: number; - latestUpdatedAt?: Date; - selectedCount: number; - onOpenArchive: () => void; - onOpenIndicators: () => void; - onOpenEquipment: () => void; -}) { - return ( -
-
-
- Всего показателей - {totalTags} -
-
- Live - {liveCount} -
-
- Выбрано для графика - {selectedCount} -
-
- Последнее обновление - {latestUpdatedAt ? formatDateTime(latestUpdatedAt) : '-'} -
-
- -
-
- Разделы -

{edgeId}

-
-
- - - - {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} - {/* */} - -
-
-
- ); -} - -/** Управляет выбором тегов и диапазона для исторического графика cloud-v3. */ -function ArchiveView({ - items, - search, - selectedTags, - history, - historyLoading, - historySource, - range, - onSearchChange, - onRangeChange, - onToggleTag, - onSelectFirstTags, - onSelectVisibleTags, - onClearVisibleTags, - onClearTags, - getTagLabel, - tagLabels, -}: { - items: CurrentItem[]; - search: string; - selectedTags: string[]; - history?: HistoryResponse; - historyLoading: boolean; - historySource?: string; - range: DateRangeState; - onSearchChange: (value: string) => void; - onRangeChange: (value: DateRangeState) => void; - onToggleTag: (tag: string) => void; - onSelectFirstTags: () => void; - onSelectVisibleTags: () => void; - onClearVisibleTags: () => void; - onClearTags: () => void; - getTagLabel: (tag: string) => string; - tagLabels: Record; -}) { - const [selectorOpen, setSelectorOpen] = useState(false); - const selectedPreview = selectedTags.slice(0, 10); - const hiddenSelectedCount = Math.max(0, selectedTags.length - selectedPreview.length); - - return ( -
-
-
- - - История - -

График параметров

-
-
- - cloud-v3 · {historySource ?? 'ожидание'} -
-
- -
- - - {selectorOpen ? ( -
-
- -
- - - - -
-
- -
- {selectedPreview.length ? selectedPreview.map((tag) => {getTagLabel(tag)}) : Не выбрано} - {hiddenSelectedCount > 0 ? +{hiddenSelectedCount} : null} -
- -
- {items.map((item) => { - const selected = selectedTags.includes(item.tag); - const label = getTagLabel(item.tag); - return ( - - ); - })} -
-
- ) : null} -
- -
-
-
- {RANGE_PRESETS.map((preset) => ( - - ))} -
- - -
- - {selectedTags.length ? ( - - ) : ( -
Разверните список показателей и выберите серии для графика
- )} -
-
- ); -} - -/** Встраивает страницу активного оборудования из ТОиР light почти на всю рабочую область. */ -function EquipmentView() { - const [iframeLoaded, setIframeLoaded] = useState(false); - - return ( -
-
- {!iframeLoaded ? ( -
- - Загрузка интерфейса управления оборудованием... -
- ) : null} - - setIframeLoaded(true)} - /> -
-
- ); -} - -/** Показывает текущие показатели edge в режимах общей картины и детальных карточек. */ -function IndicatorsView({ - items, - search, - isError, - error, - selectedTags, - getTagLabel, - onSearchChange, - onToggleTag, -}: { - items: CurrentItem[]; - search: string; - isError: boolean; - error: unknown; - selectedTags: string[]; - getTagLabel: (tag: string) => string; - onSearchChange: (value: string) => void; - onToggleTag: (tag: string) => void; -}) { - const [displayMode, setDisplayMode] = useState<'overview' | 'cards'>('overview'); - const itemStatuses = useMemo(() => { - const now = Date.now(); - return items.map((item) => ({ item, statusInfo: getMetricStatus(item, now) })); - }, [items]); - const statusCounts = itemStatuses.reduce( - (counts, { statusInfo }) => { - counts[statusInfo.status] += 1; - return counts; - }, - { normal: 0, warning: 0, critical: 0 }, - ); - - return ( -
-
-
- - - Показатели - -

Текущие значения

-
-
-
- - -
- -
-
- -
-
- Всего - {items.length} -
-
- В норме - {statusCounts.normal} -
-
- Предупреждение - {statusCounts.warning} -
-
- Критично - {statusCounts.critical} -
-
- На графике - {selectedTags.length} -
-
- - {isError ? ( -
Не удалось загрузить текущие значения: {String(error)}
- ) : displayMode === 'overview' ? ( -
- {itemStatuses.map(({ item, statusInfo }) => ( - - ))} -
- ) : ( -
- {itemStatuses.map(({ item, statusInfo }) => ( - - ))} -
- )} -
- ); -} +export { EdgeDetailPage } from '../features/edge-detail/EdgeDetailPage'; +export type { DetailView } from '../features/edge-detail/types'; diff --git a/src/pages/EdgesDashboard.tsx b/src/pages/EdgesDashboard.tsx index 89e2759..1338d3b 100644 --- a/src/pages/EdgesDashboard.tsx +++ b/src/pages/EdgesDashboard.tsx @@ -1,235 +1 @@ -import { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { ArrowRight, Clock3, Gauge, LogOut, RefreshCw, Search, ShieldAlert, Wrench } from 'lucide-react'; -import edgeImage from '../assets/edge.png'; -import { EdgeItem, getEdges } from '../api/cloud'; -import { useAuth } from '../auth/authContext'; -import { formatDateTime } from '../utils/format'; - -type EdgesDashboardProps = { - onOpenEdge: (edgeId: string) => void; - onOpenEquipment: (edgeId: string) => void; -}; - -/** Определяет базовый статус edge по наличию последних и live-данных. */ -function getEdgeState(edge: EdgeItem): 'ok' | 'warn' | 'empty' { - if (!edge.lastDataAt) { - return 'empty'; - } - - if (edge.liveTagCount > 0) { - return 'ok'; - } - - return 'warn'; -} - -/** Возвращает пользовательское имя edge, если оно отличается от технического id. */ -function getEdgeTitle(edge: EdgeItem): string { - if (edge.name && edge.name !== edge.id) { - return edge.name; - } - - return edge.id; -} - -/** Отображает стартовый dashboard со списком edge-установок и общей статистикой cloud-v3. */ -export function EdgesDashboard({ onOpenEdge, onOpenEquipment }: EdgesDashboardProps) { - const [search, setSearch] = useState(''); - const auth = useAuth(); - const edges = useQuery({ - queryKey: ['edge'], - queryFn: getEdges, - refetchInterval: 5_000, - }); - - const filteredEdges = useMemo(() => { - const query = search.trim().toLowerCase(); - const items = edges.data?.items ?? []; - - if (!query) { - return items; - } - - return items.filter((edge) => `${edge.id} ${edge.name}`.toLowerCase().includes(query)); - }, [edges.data?.items, search]); - - 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 v3 -

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

-
-
- - - {auth.enabled ? ( - - ) : null} -
-
- -
- - - - - -
- - {edges.isError ? ( -
Не удалось загрузить список буровых: {String(edges.error)}
- ) : ( -
- {filteredEdges.map((edge, index) => ( - - ))} -
- )} - - {!edges.isPending && !filteredEdges.length && !edges.isError ? ( -
В cloud-v3 пока нет буровых
- ) : null} -
- ); -} - -/** Показывает один числовой показатель общей статистики dashboard. */ -function StatBlock({ - label, - value, - tone, -}: { - label: string; - value: number; - tone: 'neutral' | 'success' | 'danger' | 'warning' | 'accent'; -}) { - return ( -
- {label} - {value} -
- ); -} - -/** Рендерит карточку edge с быстрыми переходами в рабочие разделы. */ -function EdgeCard({ - edge, - onOpenEdge, - onOpenEquipment, -}: { - edge: EdgeItem; - index: number; - onOpenEdge: (edgeId: string) => void; - onOpenEquipment: (edgeId: string) => void; -}) { - const state = getEdgeState(edge); - const title = getEdgeTitle(edge); - const stateLabel = state === 'ok' ? 'В норме' : state === 'warn' ? 'Нет live' : 'Нет данных'; - - return ( -
-
-
- -
-
-

{title}

- {edge.id} -
- - - {stateLabel} - -
- -
-
-
Показатели
-
{edge.currentTagCount}/{edge.tagCount}
-
-
-
Live
-
{edge.liveTagCount}
-
-
-
- - Последние данные -
-
{edge.lastDataAt ? formatDateTime(edge.lastDataAt) : '-'}
-
-
- -
- - - {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} - {/* */} - - -
- -
- Техническое обслуживание -
- - - - - -
-
- - -
- ); -} +export { EdgesDashboard } from '../features/edges-dashboard/EdgesDashboard'; diff --git a/src/pages/ElectricalSchematicsPage.tsx b/src/pages/ElectricalSchematicsPage.tsx index 91babad..af3f582 100644 --- a/src/pages/ElectricalSchematicsPage.tsx +++ b/src/pages/ElectricalSchematicsPage.tsx @@ -1,476 +1 @@ -import { useEffect, useMemo, useState, type CSSProperties } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Activity, AlertTriangle, CircuitBoard, DatabaseZap, PlugZap, RefreshCw } from 'lucide-react'; -import type { - CurrentEvent, - CurrentItem, - CurrentResponse, -} from '../api/cloud'; -import { getCurrent, getCurrentEventsUrl } from '../api/cloud'; -import { - getPublishedDiagramPage, - listDiagramPages, - type DiagramDocument, - type DiagramEdge, - type DiagramNode, - type DiagramPageSummary, - type DiagramPoint, -} from '../api/diagram'; -import { formatDateTime, formatNumber } from '../utils/format'; - -type ElectricalSchematicsPageProps = { - edgeId: string; -}; - -type LiveValue = CurrentItem & { - sourceKey: string; -}; - -type BindingTag = { - tagId: string; - edgeId: string; - sourceKey: string; - sseUrl?: string; -}; - -type LiveGroup = { - sourceKey: string; - edgeId: string; - sseUrl?: string; - tags: string[]; -}; - -const DEFAULT_PAGE_PREFIXES = ['ELECTRICAL', 'MAIN', 'DIRECT']; - -/** Выбирает опубликованную страницу, отдавая приоритет электросхеме текущей установки. */ -function pickDefaultPage(pages: DiagramPageSummary[], edgeId: string): DiagramPageSummary | undefined { - const published = pages.filter((page) => page.publishedRevision); - const byPriority = [...published].sort((a, b) => { - const aRank = getPageRank(a.pageKey, edgeId); - const bRank = getPageRank(b.pageKey, edgeId); - return aRank - bRank || a.pageKey.localeCompare(b.pageKey); - }); - - return byPriority[0]; -} - -/** Чем меньше ранг, тем выше страница в списке выбора опубликованных схем. */ -function getPageRank(pageKey: string, edgeId: string): number { - const exactKeys = DEFAULT_PAGE_PREFIXES.map((prefix) => (prefix === 'DIRECT' ? edgeId : `${prefix}_${edgeId}`)); - const exactIndex = exactKeys.indexOf(pageKey); - if (exactIndex >= 0) { - return exactIndex; - } - - return pageKey.includes(edgeId) ? 10 : 20; -} - -/** Собирает теги, которые надо читать по SSE, из виджетов и bindings у элементов схемы. */ -function collectBindingTags(document: DiagramDocument): BindingTag[] { - const tags: BindingTag[] = []; - - for (const node of document.nodes) { - if (node.kind === 'tagWidget') { - tags.push({ - tagId: node.tagId, - edgeId: node.edgeId || document.ownerEdgeId, - sourceKey: `edge:${node.edgeId || document.ownerEdgeId}`, - }); - continue; - } - - const bindings = node.bindings; - if (!bindings) { - continue; - } - - const edgeId = bindings.sseEdgeId || bindings.edgeId || document.ownerEdgeId; - const sourceKey = bindings.sseUrl || `edge:${edgeId}`; - - [bindings.stateTagId, bindings.alarmTagId].filter(Boolean).forEach((tagId) => { - tags.push({ - tagId: tagId!, - edgeId, - sourceKey, - sseUrl: bindings.sseUrl, - }); - }); - } - - const unique = new Map(); - tags.forEach((tag) => unique.set(`${tag.sourceKey}:${tag.edgeId}:${tag.tagId}`, tag)); - return Array.from(unique.values()); -} - -/** Группирует подписки так, чтобы один EventSource читал сразу несколько тегов одного edge/source. */ -function createLiveGroups(tags: BindingTag[]): LiveGroup[] { - const groups = new Map(); - - tags.forEach((tag) => { - const group = groups.get(tag.sourceKey) ?? { - sourceKey: tag.sourceKey, - edgeId: tag.edgeId, - sseUrl: tag.sseUrl, - tags: [], - }; - - if (!group.tags.includes(tag.tagId)) { - group.tags.push(tag.tagId); - } - - groups.set(tag.sourceKey, group); - }); - - return Array.from(groups.values()); -} - -/** Добавляет tags-параметр к пользовательскому SSE URL, если он еще не задан в настройке. */ -function buildCustomSseUrl(sseUrl: string, tags: string[]): string { - const url = new URL(sseUrl, window.location.origin); - if (tags.length && !url.searchParams.has('tags')) { - url.searchParams.set('tags', tags.join(',')); - } - - return url.toString(); -} - -/** Возвращает live-значение с учетом edge, но оставляет fallback по tag для старых схем. */ -function getLiveValue(values: Map, edgeId: string, tagId?: string): LiveValue | undefined { - if (!tagId) { - return undefined; - } - - return values.get(`${edgeId}:${tagId}`) ?? values.get(`tag:${tagId}`); -} - -/** Рассчитывает размеры полотна по опубликованным координатам элементов. */ -function getCanvasBounds(document?: DiagramDocument) { - const nodes = document?.nodes ?? []; - const maxX = Math.max(...nodes.map((node) => node.position.x + getNodeSize(node).width), 960); - const maxY = Math.max(...nodes.map((node) => node.position.y + getNodeSize(node).height), 560); - - return { - width: Math.ceil(maxX + 80), - height: Math.ceil(maxY + 80), - }; -} - -function getNodeSize(node: DiagramNode) { - if (node.kind === 'decoration') { - return node.size; - } - - return node.size ?? { width: 190, height: 82 }; -} - -function getNodeTitle(node: DiagramNode, value?: LiveValue) { - if (node.kind === 'tagWidget') { - return node.label || value?.name || node.tagId; - } - - return String(node.data?.title || node.data?.text || node.decorationType); -} - -function getNodeCenter(node: DiagramNode): DiagramPoint { - const size = getNodeSize(node); - return { - x: node.position.x + size.width / 2, - y: node.position.y + size.height / 2, - }; -} - -function getEdgePoints(edge: DiagramEdge, nodeMap: Map): DiagramPoint[] { - const source = nodeMap.get(edge.sourceNodeId); - const target = nodeMap.get(edge.targetNodeId); - if (!source || !target) { - return []; - } - - return [getNodeCenter(source), ...(edge.waypoints ?? []), getNodeCenter(target)]; -} - -function pointsToPolyline(points: DiagramPoint[]) { - return points.map((point) => `${point.x},${point.y}`).join(' '); -} - -/** Отображает опубликованную электросхему и подставляет live-значения через SSE cloud-v3. */ -export function ElectricalSchematicsPage({ edgeId }: ElectricalSchematicsPageProps) { - const [selectedPageKey, setSelectedPageKey] = useState(''); - const [liveValues, setLiveValues] = useState(() => new Map()); - const [connectedSources, setConnectedSources] = useState>(new Set()); - - const pages = useQuery({ - queryKey: ['diagram-pages', edgeId], - queryFn: () => listDiagramPages(edgeId), - enabled: Boolean(edgeId), - }); - - const publishedPages = useMemo(() => pages.data?.filter((page) => page.publishedRevision) ?? [], [pages.data]); - const selectedPage = useMemo( - () => publishedPages.find((page) => page.pageKey === selectedPageKey) ?? pickDefaultPage(publishedPages, edgeId), - [edgeId, publishedPages, selectedPageKey], - ); - - useEffect(() => { - if (selectedPage && selectedPage.pageKey !== selectedPageKey) { - setSelectedPageKey(selectedPage.pageKey); - } - }, [selectedPage, selectedPageKey]); - - const page = useQuery({ - queryKey: ['published-diagram-page', selectedPage?.pageKey], - queryFn: () => getPublishedDiagramPage(selectedPage!.pageKey), - enabled: Boolean(selectedPage?.pageKey), - }); - - const document = page.data?.document; - const bindingTags = useMemo(() => (document ? collectBindingTags(document) : []), [document]); - const liveGroups = useMemo(() => createLiveGroups(bindingTags), [bindingTags]); - const nodeMap = useMemo(() => new Map((document?.nodes ?? []).map((node) => [node.id, node])), [document?.nodes]); - const canvasBounds = useMemo(() => getCanvasBounds(document), [document]); - - useEffect(() => { - setLiveValues(new Map()); - setConnectedSources(new Set()); - }, [selectedPage?.pageKey]); - - useEffect(() => { - if (!liveGroups.length) { - return undefined; - } - - let closed = false; - const eventSources: EventSource[] = []; - - const mergeResponse = (response: CurrentResponse, sourceKey: string) => { - if (closed) { - return; - } - - setLiveValues((previous) => { - const next = new Map(previous); - response.items.forEach((item) => { - const value = { ...item, sourceKey }; - next.set(`${item.edge}:${item.tag}`, value); - next.set(`tag:${item.tag}`, value); - }); - return next; - }); - }; - - liveGroups.forEach((group) => { - if (!group.sseUrl) { - void getCurrent(group.edgeId, group.tags).then((response) => mergeResponse(response, group.sourceKey)); - } - - const url = group.sseUrl ? buildCustomSseUrl(group.sseUrl, group.tags) : getCurrentEventsUrl(group.edgeId, group.tags); - const eventSource = new EventSource(url); - - eventSource.onopen = () => { - setConnectedSources((previous) => new Set(previous).add(group.sourceKey)); - }; - - eventSource.onerror = () => { - setConnectedSources((previous) => { - const next = new Set(previous); - next.delete(group.sourceKey); - return next; - }); - }; - - eventSource.onmessage = (message) => { - const event = JSON.parse(message.data) as CurrentEvent; - mergeResponse(event, group.sourceKey); - }; - - eventSources.push(eventSource); - }); - - return () => { - closed = true; - eventSources.forEach((eventSource) => eventSource.close()); - }; - }, [liveGroups]); - - const liveTagCount = liveValues.size ? Array.from(liveValues.keys()).filter((key) => key.startsWith('tag:')).length : 0; - const connected = connectedSources.size > 0; - - return ( -
-
-
- - - Электросхемы - -

{page.data?.title || document?.title || selectedPage?.pageKey || edgeId}

-
-
- {publishedPages.length > 1 ? ( - - ) : null} -
- - {connected ? `SSE · ${connectedSources.size}` : liveGroups.length ? 'подключение SSE' : 'нет привязок'} -
- -
-
- - {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} -
-
- ); -} +export { ElectricalSchematicsPage } from '../features/electrical-schematics/ElectricalSchematicsPage'; diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts new file mode 100644 index 0000000..a2a1776 --- /dev/null +++ b/src/shared/api/http.ts @@ -0,0 +1,30 @@ +export type QueryParamValue = string | number | string[] | undefined; + +/** Выполняет GET-запрос и добавляет query-параметры в едином формате для всех API. */ +export async function getJson( + baseUrl: string, + path: string, + params?: Record, + init?: RequestInit, +): Promise { + const url = new URL(path, baseUrl); + + Object.entries(params ?? {}).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => url.searchParams.append(key, item)); + return; + } + + if (value !== undefined && value !== '') { + url.searchParams.set(key, String(value)); + } + }); + + const response = await fetch(url, init); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `${response.status} ${response.statusText}`); + } + + return response.json() as Promise; +} diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts new file mode 100644 index 0000000..27d73dd --- /dev/null +++ b/src/shared/config/env.ts @@ -0,0 +1,6 @@ +export const cloudApiUrl = import.meta.env.VITE_CLOUD_API_URL as string; +export const diagramApiUrl = import.meta.env.VITE_DIAGRAM_API_URL as string; +export const toirLightOrigin = import.meta.env.VITE_TOIR_LIGHT_ORIGIN as string; +export const keycloakUrl = import.meta.env.VITE_KEYCLOAK_URL; +export const keycloakRealm = import.meta.env.VITE_KEYCLOAK_REALM; +export const keycloakClientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID; diff --git a/src/styles/components.css b/src/styles/components.css index 68f0d95..4d3de34 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1,543 +1,3 @@ -.panel-eyebrow, -.page-kicker { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--accent-soft); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.icon-button, -.segmented button, -.ghost-button { - min-height: 38px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(201, 122, 61, 0.08); - cursor: pointer; -} - -.icon-button { - width: 38px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.ghost-button { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 0 12px; - color: #cbd5e1; -} - -.segmented { - display: inline-flex; - padding: 3px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.35); -} - -.view-switch { - display: inline-flex; - flex: 0 0 auto; - padding: 3px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.35); -} - -.segmented button { - border-color: transparent; - padding: 0 10px; -} - -.view-switch button { - min-height: 36px; - padding: 0 12px; - border: 1px solid transparent; - border-radius: calc(var(--radius) - 3px); - background: transparent; - color: var(--muted); - cursor: pointer; -} - -.view-switch button:hover, -.view-switch__button--active { - border-color: rgba(212, 165, 116, 0.34); - background: rgba(201, 122, 61, 0.14); - color: #f8fafc; -} - -.segmented button:hover, -.icon-button:hover { - border-color: var(--line-strong); - background: rgba(201, 122, 61, 0.18); -} - -.source-chip, -.status-pill, -.metric-card__state { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - font-size: 0.78rem; -} - -.source-chip { - border: 1px solid var(--line); - color: #cbd5e1; -} - -.status-pill--ok { - border: 1px solid rgba(34, 197, 94, 0.36); - background: rgba(34, 197, 94, 0.12); - color: #86efac; -} - -.status-pill--warn { - border: 1px solid rgba(245, 158, 11, 0.36); - background: rgba(245, 158, 11, 0.12); - color: #facc15; -} - -.status-pill--error { - border: 1px solid rgba(239, 68, 68, 0.36); - background: rgba(239, 68, 68, 0.12); - color: #fca5a5; -} - -.status-pill--muted { - border: 1px solid rgba(148, 163, 184, 0.22); - background: rgba(148, 163, 184, 0.08); - color: #cbd5e1; -} - -.search-box { - min-width: min(320px, 100%); - padding-left: 10px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: var(--radius); - background: rgba(15, 23, 42, 0.72); -} - -.search-box input { - flex: 1; - border: 0; - background: transparent; - padding-left: 0; -} - -.empty-panel { - min-height: 260px; - display: grid; - place-items: center; - color: var(--muted); - border: 1px dashed rgba(148, 163, 184, 0.2); - border-radius: var(--radius); -} - -.auth-screen { - min-height: 100dvh; - display: grid; - place-items: center; - padding: 24px; - background: - radial-gradient(circle at 50% 18%, rgba(201, 122, 61, 0.16), transparent 36%), - var(--bg); -} - -.auth-screen-card { - width: min(420px, 100%); - padding: 24px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 34%), - rgba(10, 13, 18, 0.88); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.07), - 0 22px 44px rgba(0, 0, 0, 0.34); -} - -.auth-screen-card h1 { - margin: 0 0 10px; - font-size: 1.4rem; -} - -.auth-screen-card p { - margin: 0; - color: var(--muted); -} - -.auth-screen-button { - min-height: 38px; - margin-top: 18px; - padding: 0 14px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(201, 122, 61, 0.12); - color: var(--text); - cursor: pointer; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 12px; -} - -.metric-mosaic { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(82px, 1fr)); - gap: 7px; -} - -.metric-tile { - position: relative; - isolation: isolate; - aspect-ratio: 1 / 0.86; - min-height: 76px; - min-width: 0; - display: grid; - align-content: space-between; - gap: 7px; - padding: 10px 9px 9px; - border: 1px solid rgba(88, 103, 121, 0.5); - border-radius: 6px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 32%), - repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 9px), - linear-gradient(145deg, rgba(20, 27, 36, 0.96), rgba(5, 8, 13, 0.92)); - cursor: pointer; - text-align: left; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - inset 0 -1px 0 rgba(0, 0, 0, 0.55), - 0 8px 18px rgba(0, 0, 0, 0.18); - transition: - border-color 160ms ease, - background 160ms ease, - box-shadow 160ms ease, - transform 160ms ease; -} - -.metric-tile::before { - content: ""; - position: absolute; - inset: 0 auto 0 0; - z-index: -1; - width: 4px; - border-radius: 6px 0 0 6px; - background: currentColor; - box-shadow: 0 0 16px currentColor; - opacity: 0.94; -} - -.metric-tile::after { - content: ""; - position: absolute; - inset: 5px; - z-index: -1; - border: 1px solid rgba(255, 255, 255, 0.045); - border-radius: 4px; - pointer-events: none; -} - -.metric-tile:hover, -.metric-tile--selected { - border-color: rgba(232, 201, 160, 0.58); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.1), - inset 0 -1px 0 rgba(0, 0, 0, 0.6), - 0 10px 22px rgba(0, 0, 0, 0.26); - transform: translateY(-1px); -} - -.metric-tile--normal { - color: #22c55e; -} - -.metric-tile--warning { - color: #f59e0b; -} - -.metric-tile--critical { - color: #ef4444; -} - -.metric-tile__status { - width: 10px; - height: 10px; - border: 1px solid rgba(255, 255, 255, 0.28); - border-radius: 999px; - background: currentColor; - box-shadow: - 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent), - 0 0 16px currentColor; -} - -.metric-tile__tag, -.metric-tile strong { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.metric-tile__tag { - color: #cbd5e1; - font-family: "Segoe UI", Inter, system-ui, sans-serif; - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.02em; -} - -.metric-tile strong { - color: #f8fafc; - font-size: 0.88rem; - font-variant-numeric: tabular-nums; - line-height: 1; -} - -.metric-card { - position: relative; - overflow: hidden; - min-height: 184px; - text-align: left; - padding: 16px 15px 15px 17px; - border: 1px solid rgba(88, 103, 121, 0.52); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 30%), - repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 11px), - linear-gradient(145deg, rgba(18, 25, 34, 0.95), rgba(5, 8, 13, 0.92)); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - inset 0 -1px 0 rgba(0, 0, 0, 0.52), - 0 14px 28px rgba(0, 0, 0, 0.2); - cursor: pointer; - transition: - border-color 160ms ease, - background 160ms ease, - box-shadow 160ms ease, - transform 160ms ease; -} - -.metric-card::before { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 5px; - background: currentColor; - box-shadow: 0 0 18px currentColor; -} - -.metric-card::after { - content: ""; - position: absolute; - inset: 7px; - border: 1px solid rgba(255, 255, 255, 0.045); - border-radius: 5px; - pointer-events: none; -} - -.metric-card:hover, -.metric-card--selected { - border-color: rgba(232, 201, 160, 0.58); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 30%), - repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.032) 0 1px, transparent 1px 11px), - linear-gradient(145deg, rgba(27, 35, 46, 0.98), rgba(11, 14, 20, 0.94)); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.1), - inset 0 -1px 0 rgba(0, 0, 0, 0.58), - 0 18px 32px rgba(0, 0, 0, 0.28); - transform: translateY(-1px); -} - -.metric-card--normal { - color: #22c55e; -} - -.metric-card--normal .metric-card__signal { - color: #86efac; - border-color: rgba(34, 197, 94, 0.24); - background: rgba(34, 197, 94, 0.08); -} - -.metric-card--warning { - color: #f59e0b; -} - -.metric-card--warning .metric-card__signal { - color: #facc15; - border-color: rgba(245, 158, 11, 0.28); - background: rgba(245, 158, 11, 0.08); -} - -.metric-card--critical { - color: #ef4444; -} - -.metric-card--critical .metric-card__signal { - color: #fca5a5; - border-color: rgba(239, 68, 68, 0.3); - background: rgba(239, 68, 68, 0.1); -} - -.metric-card__header { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: start; - gap: 9px; -} - -.metric-card__footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; -} - -.metric-card__signal { - width: 30px; - height: 30px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid rgba(212, 165, 116, 0.24); - border-radius: 6px; - color: var(--accent-soft); - background: - repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 7px), - rgba(2, 6, 23, 0.42); -} - -.metric-card__tag { - min-width: 0; - 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; -} - -.metric-card__corner { - display: inline-flex; - align-items: flex-end; - flex-direction: column; - gap: 6px; -} - -.metric-card__age { - display: inline-flex; - align-items: center; - gap: 5px; - color: #9aa8ba; - font-size: 0.73rem; - font-variant-numeric: tabular-nums; -} - -.metric-card__state.is-normal { - border-color: rgba(34, 197, 94, 0.24); - background: rgba(34, 197, 94, 0.1); - color: #86efac; -} - -.metric-card__state.is-warning { - border-color: rgba(245, 158, 11, 0.28); - background: rgba(245, 158, 11, 0.1); - color: #facc15; -} - -.metric-card__state.is-critical { - border-color: rgba(239, 68, 68, 0.3); - background: rgba(239, 68, 68, 0.12); - color: #fca5a5; -} - -.metric-card__value { - margin: 22px 0 16px; - overflow: hidden; - font-size: clamp(1.85rem, 4vw, 2.7rem); - font-variant-numeric: tabular-nums; - font-weight: 760; - line-height: 1; - color: #f8fafc; - text-overflow: ellipsis; - white-space: nowrap; - text-shadow: 0 0 18px color-mix(in srgb, currentColor 18%, transparent); -} - -.metric-card__footer { - margin-top: 18px; - padding-top: 12px; - border-top: 1px solid rgba(148, 163, 184, 0.12); - color: var(--muted); - font-size: 0.78rem; -} - -.metric-card__footer span { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.metric-card__action { - flex: 0 0 auto; - color: var(--accent-soft); - font-weight: 700; -} - -@media (max-width: 680px) { - .search-box { - width: 100%; - } - - .segmented { - width: 100%; - overflow-x: auto; - } - - .view-switch { - width: 100%; - } - - .view-switch button { - flex: 1; - } -} +@import './components/controls.css'; +@import './components/auth.css'; +@import './components/metrics.css'; diff --git a/src/styles/components/auth.css b/src/styles/components/auth.css new file mode 100644 index 0000000..7dfef71 --- /dev/null +++ b/src/styles/components/auth.css @@ -0,0 +1,43 @@ +.auth-screen { + min-height: 100dvh; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(circle at 50% 18%, rgba(201, 122, 61, 0.16), transparent 36%), + var(--bg); +} + +.auth-screen-card { + width: min(420px, 100%); + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 34%), + rgba(10, 13, 18, 0.88); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 22px 44px rgba(0, 0, 0, 0.34); +} + +.auth-screen-card h1 { + margin: 0 0 10px; + font-size: 1.4rem; +} + +.auth-screen-card p { + margin: 0; + color: var(--muted); +} + +.auth-screen-button { + min-height: 38px; + margin-top: 18px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(201, 122, 61, 0.12); + color: var(--text); + cursor: pointer; +} diff --git a/src/styles/components/controls.css b/src/styles/components/controls.css new file mode 100644 index 0000000..a797e61 --- /dev/null +++ b/src/styles/components/controls.css @@ -0,0 +1,158 @@ +.panel-eyebrow, +.page-kicker { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent-soft); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.icon-button, +.segmented button, +.ghost-button { + min-height: 38px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(201, 122, 61, 0.08); + cursor: pointer; +} + +.icon-button { + width: 38px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ghost-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 12px; + color: #cbd5e1; +} + +.segmented, +.view-switch { + display: inline-flex; + flex: 0 0 auto; + padding: 3px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.35); +} + +.segmented button { + border-color: transparent; + padding: 0 10px; +} + +.view-switch button { + min-height: 36px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 3px); + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.view-switch button:hover, +.view-switch__button--active { + border-color: rgba(212, 165, 116, 0.34); + background: rgba(201, 122, 61, 0.14); + color: #f8fafc; +} + +.segmented button:hover, +.icon-button:hover { + border-color: var(--line-strong); + background: rgba(201, 122, 61, 0.18); +} + +.source-chip, +.status-pill, +.metric-card__state { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + font-size: 0.78rem; +} + +.source-chip { + border: 1px solid var(--line); + color: #cbd5e1; +} + +.status-pill--ok { + border: 1px solid rgba(34, 197, 94, 0.36); + background: rgba(34, 197, 94, 0.12); + color: #86efac; +} + +.status-pill--warn { + border: 1px solid rgba(245, 158, 11, 0.36); + background: rgba(245, 158, 11, 0.12); + color: #facc15; +} + +.status-pill--error { + border: 1px solid rgba(239, 68, 68, 0.36); + background: rgba(239, 68, 68, 0.12); + color: #fca5a5; +} + +.status-pill--muted { + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(148, 163, 184, 0.08); + color: #cbd5e1; +} + +.search-box { + min-width: min(320px, 100%); + padding-left: 10px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: var(--radius); + background: rgba(15, 23, 42, 0.72); +} + +.search-box input { + flex: 1; + border: 0; + background: transparent; + padding-left: 0; +} + +.empty-panel { + min-height: 260px; + display: grid; + place-items: center; + color: var(--muted); + border: 1px dashed rgba(148, 163, 184, 0.2); + border-radius: var(--radius); +} + +@media (max-width: 680px) { + .search-box { + width: 100%; + } + + .segmented { + width: 100%; + overflow-x: auto; + } + + .view-switch { + width: 100%; + } + + .view-switch button { + flex: 1; + } +} diff --git a/src/styles/components/metrics.css b/src/styles/components/metrics.css new file mode 100644 index 0000000..6a0db96 --- /dev/null +++ b/src/styles/components/metrics.css @@ -0,0 +1,324 @@ +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.metric-mosaic { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(82px, 1fr)); + gap: 7px; +} + +.metric-tile { + position: relative; + isolation: isolate; + aspect-ratio: 1 / 0.86; + min-height: 76px; + min-width: 0; + display: grid; + align-content: space-between; + gap: 7px; + padding: 10px 9px 9px; + border: 1px solid rgba(88, 103, 121, 0.5); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 32%), + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 9px), + linear-gradient(145deg, rgba(20, 27, 36, 0.96), rgba(5, 8, 13, 0.92)); + cursor: pointer; + text-align: left; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 0 rgba(0, 0, 0, 0.55), + 0 8px 18px rgba(0, 0, 0, 0.18); + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.metric-tile::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + z-index: -1; + width: 4px; + border-radius: 6px 0 0 6px; + background: currentColor; + box-shadow: 0 0 16px currentColor; + opacity: 0.94; +} + +.metric-tile::after { + content: ""; + position: absolute; + inset: 5px; + z-index: -1; + border: 1px solid rgba(255, 255, 255, 0.045); + border-radius: 4px; + pointer-events: none; +} + +.metric-tile:hover, +.metric-tile--selected { + border-color: rgba(232, 201, 160, 0.58); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 0 rgba(0, 0, 0, 0.6), + 0 10px 22px rgba(0, 0, 0, 0.26); + transform: translateY(-1px); +} + +.metric-tile--normal, +.metric-card--normal { + color: #22c55e; +} + +.metric-tile--warning, +.metric-card--warning { + color: #f59e0b; +} + +.metric-tile--critical, +.metric-card--critical { + color: #ef4444; +} + +.metric-tile__status { + width: 10px; + height: 10px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 999px; + background: currentColor; + box-shadow: + 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent), + 0 0 16px currentColor; +} + +.metric-tile__tag, +.metric-tile strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-tile__tag { + color: #cbd5e1; + font-family: "Segoe UI", Inter, system-ui, sans-serif; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.metric-tile strong { + color: #f8fafc; + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.metric-card { + position: relative; + overflow: hidden; + min-height: 184px; + text-align: left; + padding: 16px 15px 15px 17px; + border: 1px solid rgba(88, 103, 121, 0.52); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 30%), + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 11px), + linear-gradient(145deg, rgba(18, 25, 34, 0.95), rgba(5, 8, 13, 0.92)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 0 rgba(0, 0, 0, 0.52), + 0 14px 28px rgba(0, 0, 0, 0.2); + cursor: pointer; + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.metric-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 5px; + background: currentColor; + box-shadow: 0 0 18px currentColor; +} + +.metric-card::after { + content: ""; + position: absolute; + inset: 7px; + border: 1px solid rgba(255, 255, 255, 0.045); + border-radius: 5px; + pointer-events: none; +} + +.metric-card:hover, +.metric-card--selected { + border-color: rgba(232, 201, 160, 0.58); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 30%), + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.032) 0 1px, transparent 1px 11px), + linear-gradient(145deg, rgba(27, 35, 46, 0.98), rgba(11, 14, 20, 0.94)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 0 rgba(0, 0, 0, 0.58), + 0 18px 32px rgba(0, 0, 0, 0.28); + transform: translateY(-1px); +} + +.metric-card--normal .metric-card__signal { + color: #86efac; + border-color: rgba(34, 197, 94, 0.24); + background: rgba(34, 197, 94, 0.08); +} + +.metric-card--warning .metric-card__signal { + color: #facc15; + border-color: rgba(245, 158, 11, 0.28); + background: rgba(245, 158, 11, 0.08); +} + +.metric-card--critical .metric-card__signal { + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.1); +} + +.metric-card__header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 9px; +} + +.metric-card__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.metric-card__signal { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(212, 165, 116, 0.24); + border-radius: 6px; + color: var(--accent-soft); + background: + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 7px), + rgba(2, 6, 23, 0.42); +} + +.metric-card__tag { + min-width: 0; + 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; +} + +.metric-card__corner { + display: inline-flex; + align-items: flex-end; + flex-direction: column; + gap: 6px; +} + +.metric-card__age { + display: inline-flex; + align-items: center; + gap: 5px; + color: #9aa8ba; + font-size: 0.73rem; + font-variant-numeric: tabular-nums; +} + +.metric-card__state.is-normal { + border-color: rgba(34, 197, 94, 0.24); + background: rgba(34, 197, 94, 0.1); + color: #86efac; +} + +.metric-card__state.is-warning { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(245, 158, 11, 0.1); + color: #facc15; +} + +.metric-card__state.is-critical { + border-color: rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.12); + color: #fca5a5; +} + +.metric-card__value { + margin: 22px 0 16px; + overflow: hidden; + font-size: clamp(1.85rem, 4vw, 2.7rem); + font-variant-numeric: tabular-nums; + font-weight: 760; + line-height: 1; + color: #f8fafc; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 0 18px color-mix(in srgb, currentColor 18%, transparent); +} + +.metric-card__footer { + margin-top: 18px; + padding-top: 12px; + border-top: 1px solid rgba(148, 163, 184, 0.12); + color: var(--muted); + font-size: 0.78rem; +} + +.metric-card__footer span { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-card__action { + flex: 0 0 auto; + color: var(--accent-soft); + font-weight: 700; +} diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css index d70924a..251a591 100644 --- a/src/styles/dashboard.css +++ b/src/styles/dashboard.css @@ -1,411 +1,2 @@ -.dashboard-shell { - width: min(1440px, 100%); - min-height: 100vh; - margin: 0 auto; - padding: 26px 24px 36px; -} - -.dashboard-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - margin-bottom: 22px; -} - -.dashboard-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - flex-wrap: wrap; -} - -.dashboard-search { - width: min(360px, 100%); -} - -.dashboard-stats { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 22px; -} - -.dashboard-stat { - min-height: 112px; - padding: 16px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: - linear-gradient(145deg, rgba(17, 24, 39, 0.78), rgba(2, 6, 23, 0.62)), - rgba(2, 6, 23, 0.38); -} - -.dashboard-stat span { - display: block; - min-height: 34px; - color: var(--muted); - font-size: 0.78rem; - line-height: 1.35; -} - -.dashboard-stat strong { - display: block; - margin-top: 12px; - font-size: 2rem; - line-height: 1; -} - -.dashboard-stat--success { - border-color: rgba(34, 197, 94, 0.28); -} - -.dashboard-stat--success strong { - color: #86efac; -} - -.dashboard-stat--danger { - border-color: rgba(239, 68, 68, 0.28); -} - -.dashboard-stat--danger strong { - color: #fca5a5; -} - -.dashboard-stat--warning { - border-color: rgba(245, 158, 11, 0.3); -} - -.dashboard-stat--warning strong { - color: #facc15; -} - -.dashboard-stat--accent { - border-color: rgba(212, 165, 116, 0.32); -} - -.dashboard-stat--accent strong { - color: var(--accent-soft); -} - -.edge-card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - gap: 16px; -} - -.edge-card { - min-height: 520px; - display: flex; - flex-direction: column; - padding: 18px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 10px; - background: - radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.09), transparent 26%), - linear-gradient(145deg, rgba(23, 30, 42, 0.92), rgba(4, 9, 16, 0.84)), - var(--panel-strong); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.07), - 0 18px 34px rgba(0, 0, 0, 0.22); - transition: - border-color 160ms ease, - transform 160ms ease, - box-shadow 160ms ease; -} - -.edge-card:hover { - border-color: rgba(212, 165, 116, 0.42); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 24px 44px rgba(0, 0, 0, 0.28); - transform: translateY(-1px); -} - -.edge-card--ok { - border-color: rgba(34, 197, 94, 0.26); -} - -.edge-card--warn { - border-color: rgba(245, 158, 11, 0.3); -} - -.edge-card--empty { - border-color: rgba(148, 163, 184, 0.18); -} - -.edge-card__header { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} - -.edge-card__logo { - flex: 0 0 auto; - width: 64px; - height: 64px; - display: grid; - place-items: center; - border: 1px solid rgba(212, 165, 116, 0.22); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.38); -} - -.edge-card__logo img { - width: 46px; - height: 52px; - object-fit: contain; - filter: - sepia(1) - saturate(1.25) - hue-rotate(342deg) - brightness(0.86) - contrast(1.1); -} - -.edge-card__title { - min-width: 0; - flex: 1; -} - -.edge-card__title h2 { - margin: 0; - overflow: hidden; - font-size: 1.08rem; - font-weight: 650; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.edge-card__title span { - display: block; - margin-top: 5px; - overflow: hidden; - color: var(--muted); - font-size: 0.77rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.edge-state { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 0 9px; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 999px; - font-size: 0.68rem; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.edge-state--ok { - color: #22e27f; -} - -.edge-state--warn, -.edge-state--empty { - color: #f2a23a; -} - -.edge-card__meta { - margin: 18px 0 12px; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.edge-card__meta div { - min-height: 64px; - padding: 10px; - border: 1px solid rgba(148, 163, 184, 0.13); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.26); -} - -.edge-card__meta div:last-child { - grid-column: 1 / -1; - min-height: 52px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - column-gap: 12px; -} - -.edge-card__meta dt { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - color: var(--muted); - font-size: 0.74rem; -} - -.edge-card__meta dd { - margin: 8px 0 0; - overflow: hidden; - color: #e5e7eb; - font-size: 1.02rem; - font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; -} - -.edge-card__meta div:last-child dd { - margin: 0; - font-size: 0.92rem; -} - -.edge-card__actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-bottom: 14px; -} - -.edge-card__actions button, -.edge-card__maintenance button { - min-width: 0; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.24); - color: #cbd5e1; - cursor: pointer; - transition: - border-color 160ms ease, - background 160ms ease, - color 160ms ease; -} - -.edge-card__actions button { - min-height: 40px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 10px; - color: #b8c3d3; - font-size: 0.76rem; - text-align: left; -} - -.edge-card__actions button:hover, -.edge-card__maintenance button:hover { - border-color: rgba(212, 165, 116, 0.42); - background: rgba(201, 122, 61, 0.1); - color: #f8fafc; -} - -.edge-card__maintenance { - padding: 12px; - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.2); -} - -.edge-card__maintenance > span { - display: block; - margin-bottom: 9px; - color: var(--muted); - font-size: 0.72rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.edge-card__maintenance div { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 7px; -} - -.edge-card__maintenance button { - min-height: 34px; - padding: 0 8px; - font-size: 0.72rem; -} - -.edge-card__details { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 44px; - margin-top: auto; - padding: 0 12px; - border: 1px solid rgba(212, 165, 116, 0.28); - border-radius: var(--radius); - background: - linear-gradient(135deg, rgba(201, 122, 61, 0.14), rgba(212, 165, 116, 0.06)), - rgba(201, 122, 61, 0.08); - color: #f8fafc; - cursor: pointer; - font-size: 0.76rem; - font-weight: 900; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.edge-card__details:hover { - border-color: var(--line-strong); - background: rgba(201, 122, 61, 0.16); -} - -@media (max-width: 1100px) { - .dashboard-stats { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .edge-card-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 680px) { - .dashboard-shell { - padding: 16px; - } - - .dashboard-header { - align-items: stretch; - flex-direction: column; - } - - .dashboard-actions, - .dashboard-search { - width: 100%; - } - - .edge-card-grid { - gap: 16px; - grid-template-columns: 1fr; - } - - .edge-card { - min-height: 0; - padding: 18px; - } - - .edge-card__actions, - .edge-card__maintenance div { - grid-template-columns: 1fr; - } - - .dashboard-stats { - grid-template-columns: 1fr; - } - - .edge-card__header { - align-items: flex-start; - } - - .edge-state { - margin-left: auto; - } -} +@import './dashboard/layout.css'; +@import './dashboard/edge-card.css'; diff --git a/src/styles/dashboard/edge-card.css b/src/styles/dashboard/edge-card.css new file mode 100644 index 0000000..87d6ebc --- /dev/null +++ b/src/styles/dashboard/edge-card.css @@ -0,0 +1,210 @@ +.edge-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 16px; +} + +.edge-card { + min-height: 520px; + display: flex; + flex-direction: column; + padding: 18px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 10px; + background: + radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.09), transparent 26%), + linear-gradient(145deg, rgba(23, 30, 42, 0.92), rgba(4, 9, 16, 0.84)), + var(--panel-strong); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 18px 34px rgba(0, 0, 0, 0.22); + transition: + border-color 160ms ease, + transform 160ms ease, + box-shadow 160ms ease; +} + +.edge-card:hover { + border-color: rgba(212, 165, 116, 0.42); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 24px 44px rgba(0, 0, 0, 0.28); + transform: translateY(-1px); +} + +.edge-card__header { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.edge-card__logo { + flex: 0 0 auto; + width: 64px; + height: 64px; + display: grid; + place-items: center; + border: 1px solid rgba(212, 165, 116, 0.22); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.38); +} + +.edge-card__logo img { + width: 46px; + height: 52px; + object-fit: contain; + filter: + sepia(1) + saturate(1.25) + hue-rotate(342deg) + brightness(0.86) + contrast(1.1); +} + +.edge-card__title { + min-width: 0; + flex: 1; +} + +.edge-card__title h2 { + margin: 0; + overflow: hidden; + font-size: 1.08rem; + font-weight: 650; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.edge-card__title span { + display: block; + margin-top: 5px; + overflow: hidden; + color: var(--muted); + font-size: 0.77rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.edge-card__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.edge-card__actions button, +.edge-card__maintenance button { + min-width: 0; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.24); + color: #cbd5e1; + cursor: pointer; + transition: + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.edge-card__actions button { + min-height: 40px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + color: #b8c3d3; + font-size: 0.76rem; + text-align: left; +} + +.edge-card__actions button:hover, +.edge-card__maintenance button:hover { + border-color: rgba(212, 165, 116, 0.42); + background: rgba(201, 122, 61, 0.1); + color: #f8fafc; +} + +.edge-card__maintenance { + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.2); +} + +.edge-card__maintenance > span { + display: block; + margin-bottom: 9px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.edge-card__maintenance div { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} + +.edge-card__maintenance button { + min-height: 34px; + padding: 0 8px; + font-size: 0.72rem; +} + +.edge-card__details { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; + margin-top: auto; + padding: 0 12px; + border: 1px solid rgba(212, 165, 116, 0.28); + border-radius: var(--radius); + background: + linear-gradient(135deg, rgba(201, 122, 61, 0.14), rgba(212, 165, 116, 0.06)), + rgba(201, 122, 61, 0.08); + color: #f8fafc; + cursor: pointer; + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.edge-card__details:hover { + border-color: var(--line-strong); + background: rgba(201, 122, 61, 0.16); +} + +@media (max-width: 1100px) { + .edge-card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .edge-card-grid { + gap: 16px; + grid-template-columns: 1fr; + } + + .edge-card { + min-height: 0; + padding: 18px; + } + + .edge-card__actions, + .edge-card__maintenance div { + grid-template-columns: 1fr; + } + + .edge-card__header { + align-items: flex-start; + } +} diff --git a/src/styles/dashboard/layout.css b/src/styles/dashboard/layout.css new file mode 100644 index 0000000..23a0cf1 --- /dev/null +++ b/src/styles/dashboard/layout.css @@ -0,0 +1,116 @@ +.dashboard-shell { + width: min(1440px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: 26px 24px 36px; +} + +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; +} + +.dashboard-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.dashboard-search { + width: min(360px, 100%); +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 22px; +} + +.dashboard-stat { + min-height: 112px; + padding: 16px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: + linear-gradient(145deg, rgba(17, 24, 39, 0.78), rgba(2, 6, 23, 0.62)), + rgba(2, 6, 23, 0.38); +} + +.dashboard-stat span { + display: block; + min-height: 34px; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.35; +} + +.dashboard-stat strong { + display: block; + margin-top: 12px; + font-size: 2rem; + line-height: 1; +} + +.dashboard-stat--success { + border-color: rgba(34, 197, 94, 0.28); +} + +.dashboard-stat--success strong { + color: #86efac; +} + +.dashboard-stat--danger { + border-color: rgba(239, 68, 68, 0.28); +} + +.dashboard-stat--danger strong { + color: #fca5a5; +} + +.dashboard-stat--warning { + border-color: rgba(245, 158, 11, 0.3); +} + +.dashboard-stat--warning strong { + color: #facc15; +} + +.dashboard-stat--accent { + border-color: rgba(212, 165, 116, 0.32); +} + +.dashboard-stat--accent strong { + color: var(--accent-soft); +} + +@media (max-width: 1100px) { + .dashboard-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .dashboard-shell { + padding: 16px; + } + + .dashboard-header { + align-items: stretch; + flex-direction: column; + } + + .dashboard-actions, + .dashboard-search { + width: 100%; + } + + .dashboard-stats { + grid-template-columns: 1fr; + } +} diff --git a/src/styles/detail.css b/src/styles/detail.css index 4767eec..31b86da 100644 --- a/src/styles/detail.css +++ b/src/styles/detail.css @@ -1,724 +1,6 @@ -.detail-overview { - display: grid; - 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); - border-radius: var(--radius); - background: - radial-gradient(circle at 12% 8%, rgba(255, 255, 255, 0.08), transparent 38%), - linear-gradient(145deg, rgba(17, 24, 39, 0.9), rgba(2, 6, 23, 0.78)); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.06), - 0 18px 36px rgba(0, 0, 0, 0.28); -} - -.summary-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} - -.summary-card { - min-height: 149px; - padding: 18px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(10, 13, 18, 0.62); -} - -.summary-card span { - display: block; - color: var(--muted); - font-size: 0.78rem; - text-transform: uppercase; - letter-spacing: 0.12em; -} - -.summary-card strong { - display: block; - margin-top: 18px; - font-size: clamp(1.5rem, 3vw, 2.3rem); - line-height: 1.1; -} - -.detail-action-panel { - padding: 18px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(10, 13, 18, 0.58); -} - -.detail-action-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin-top: 16px; -} - -.detail-action-grid button { - min-height: 92px; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 10px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.28); - color: #cbd5e1; - cursor: pointer; -} - -.detail-action-grid button:hover { - border-color: var(--line-strong); - background: rgba(201, 122, 61, 0.12); - color: var(--text); -} - -.chart-section, -.tags-section { - padding: 18px; - margin-bottom: 18px; -} - -.indicator-summary { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 14px; -} - -.indicator-summary div { - position: relative; - overflow: hidden; - min-height: 76px; - padding: 13px; - border: 1px solid rgba(88, 103, 121, 0.46); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 34%), - repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.024) 0 1px, transparent 1px 10px), - rgba(2, 6, 23, 0.34); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.07), - inset 0 -1px 0 rgba(0, 0, 0, 0.48); -} - -.indicator-summary div::before { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: rgba(212, 165, 116, 0.58); -} - -.indicator-summary span { - display: block; - color: #9aa8ba; - font-size: 0.74rem; - font-weight: 750; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.indicator-summary strong { - display: block; - margin-top: 9px; - color: #f8fafc; - font-size: 1.65rem; - font-variant-numeric: tabular-nums; - line-height: 1; -} - -.chart-section--archive { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - margin-bottom: 0; -} - -.equipment-section { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - margin: 0; -} - -.equipment-frame-shell { - position: relative; - flex: 1; - min-height: 0; - overflow: hidden; - border: 1px solid rgba(88, 103, 121, 0.5); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 32%), - repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 11px), - rgba(2, 6, 23, 0.58); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - inset 0 -1px 0 rgba(0, 0, 0, 0.52); -} - -.equipment-frame-loading { - position: absolute; - inset: 0; - z-index: 2; - display: grid; - place-items: center; - gap: 12px; - align-content: center; - color: #cbd5e1; - background: rgba(2, 6, 23, 0.74); -} - -.equipment-frame-loading__ring { - width: 34px; - height: 34px; - border: 3px solid rgba(212, 165, 116, 0.22); - border-top-color: var(--accent-soft); - border-radius: 999px; - animation: equipmentFrameSpin 900ms linear infinite; -} - -.equipment-frame { - width: 100%; - height: 100%; - border: 0; - 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; -} - -.indicator-controls { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - min-width: min(560px, 100%); -} - -.toolbar { - margin-bottom: 14px; -} - -.archive-tag-panel { - margin-bottom: 14px; -} - -.archive-tag-panel__toggle { - width: 100%; - min-height: 48px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 0 14px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.28); - color: #cbd5e1; - cursor: pointer; - text-align: left; -} - -.archive-tag-panel__toggle span { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.archive-tag-panel__toggle strong { - color: var(--accent-soft); - font-size: 0.78rem; - font-weight: 700; -} - -.archive-tag-panel__toggle[aria-expanded="true"] svg { - transform: rotate(180deg); -} - -.tag-selector { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 10px; - padding: 14px; - border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.24); -} - -.tag-selector__header { - display: grid; - grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.9fr); - gap: 10px; -} - -.tag-selector__header span, -.tag-selector__tools button, -.selected-tags span { - color: var(--muted); - font-size: 0.76rem; -} - -.tag-selector__header strong { - color: var(--accent-soft); - font-size: 0.86rem; -} - -.tag-selector__tools { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.tag-selector__tools button { - min-height: 34px; - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: var(--radius); - background: rgba(15, 23, 42, 0.54); - cursor: pointer; -} - -.tag-selector__tools button:hover, -.tag-select-item:hover, -.tag-select-item--selected { - border-color: var(--line-strong); - background: rgba(201, 122, 61, 0.12); -} - -.selected-tags { - min-height: 38px; - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} - -.selected-tags span { - max-width: 100%; - overflow: hidden; - padding: 5px 8px; - border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 999px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tag-select-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 7px; - padding-right: 2px; -} - -.tag-select-item { - min-height: 42px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: var(--radius); - background: rgba(2, 6, 23, 0.22); - color: #cbd5e1; - cursor: pointer; - text-align: left; -} - -.tag-select-item__name { - min-width: 0; -} - -.tag-select-item__name span, -.tag-select-item__name small { - display: block; - min-width: 0; - overflow: hidden; - 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; -} - -.tag-select-item--selected span { - color: #f8fafc; -} - -.archive-chart { - min-width: 0; - min-height: 0; - display: flex; - flex: 1; - flex-direction: column; -} - -.chart-section--archive .toolbar { - flex: 0 0 auto; -} - -@media (max-width: 1200px) { - .summary-grid, - .detail-action-grid, - .indicator-summary { - 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; - } - - .indicator-controls { - min-width: 100%; - justify-content: flex-start; - } -} - -@media (max-width: 680px) { - .summary-grid, - .detail-action-grid, - .indicator-summary { - grid-template-columns: 1fr; - } - - .tag-selector__tools { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .indicator-controls { - 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; - } -} +@import './detail/base.css'; +@import './detail/overview.css'; +@import './detail/indicators.css'; +@import './detail/archive.css'; +@import './detail/equipment.css'; +@import './detail/electrical.css'; diff --git a/src/styles/detail/archive.css b/src/styles/detail/archive.css new file mode 100644 index 0000000..8b140f8 --- /dev/null +++ b/src/styles/detail/archive.css @@ -0,0 +1,194 @@ +.chart-section--archive { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-bottom: 0; +} + +.toolbar { + margin-bottom: 14px; +} + +.archive-tag-panel { + margin-bottom: 14px; +} + +.archive-tag-panel__toggle { + width: 100%; + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 14px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.28); + color: #cbd5e1; + cursor: pointer; + text-align: left; +} + +.archive-tag-panel__toggle span { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.archive-tag-panel__toggle strong { + color: var(--accent-soft); + font-size: 0.78rem; + font-weight: 700; +} + +.archive-tag-panel__toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +.tag-selector { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 10px; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.24); +} + +.tag-selector__header { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.9fr); + gap: 10px; +} + +.tag-selector__header span, +.tag-selector__tools button, +.selected-tags span { + color: var(--muted); + font-size: 0.76rem; +} + +.tag-selector__header strong { + color: var(--accent-soft); + font-size: 0.86rem; +} + +.tag-selector__tools { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.tag-selector__tools button { + min-height: 34px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(15, 23, 42, 0.54); + cursor: pointer; +} + +.tag-selector__tools button:hover, +.tag-select-item:hover, +.tag-select-item--selected { + border-color: var(--line-strong); + background: rgba(201, 122, 61, 0.12); +} + +.selected-tags { + min-height: 38px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.selected-tags span { + max-width: 100%; + overflow: hidden; + padding: 5px 8px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 999px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-select-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 7px; + padding-right: 2px; +} + +.tag-select-item { + min-height: 42px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.22); + color: #cbd5e1; + cursor: pointer; + text-align: left; +} + +.tag-select-item__name { + min-width: 0; +} + +.tag-select-item__name span, +.tag-select-item__name small { + display: block; + min-width: 0; + overflow: hidden; + 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; +} + +.tag-select-item--selected span { + color: #f8fafc; +} + +.archive-chart { + min-width: 0; + min-height: 0; + display: flex; + flex: 1; + flex-direction: column; +} + +.chart-section--archive .toolbar { + flex: 0 0 auto; +} + +@media (max-width: 1200px) { + .tag-selector__header { + grid-template-columns: 1fr; + } +} + +@media (max-width: 680px) { + .tag-selector__tools { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/styles/detail/base.css b/src/styles/detail/base.css new file mode 100644 index 0000000..0adb7ed --- /dev/null +++ b/src/styles/detail/base.css @@ -0,0 +1,45 @@ +.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 { + padding: 18px; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: + radial-gradient(circle at 12% 8%, rgba(255, 255, 255, 0.08), transparent 38%), + linear-gradient(145deg, rgba(17, 24, 39, 0.9), rgba(2, 6, 23, 0.78)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 18px 36px rgba(0, 0, 0, 0.28); +} + +.section-header { + justify-content: space-between; + margin-bottom: 16px; +} diff --git a/src/styles/detail/electrical.css b/src/styles/detail/electrical.css new file mode 100644 index 0000000..903c20e --- /dev/null +++ b/src/styles/detail/electrical.css @@ -0,0 +1,278 @@ +.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 electricalWireFlow { + to { + stroke-dashoffset: -28; + } +} + +@media (max-width: 1200px) { + .electrical-layout { + grid-template-columns: 1fr; + } + + .electrical-side-panel { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .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/detail/equipment.css b/src/styles/detail/equipment.css new file mode 100644 index 0000000..52c6a22 --- /dev/null +++ b/src/styles/detail/equipment.css @@ -0,0 +1,57 @@ +.equipment-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin: 0; +} + +.equipment-frame-shell { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + border: 1px solid rgba(88, 103, 121, 0.5); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 32%), + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 11px), + rgba(2, 6, 23, 0.58); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 0 rgba(0, 0, 0, 0.52); +} + +.equipment-frame-loading { + position: absolute; + inset: 0; + z-index: 2; + display: grid; + place-items: center; + gap: 12px; + align-content: center; + color: #cbd5e1; + background: rgba(2, 6, 23, 0.74); +} + +.equipment-frame-loading__ring { + width: 34px; + height: 34px; + border: 3px solid rgba(212, 165, 116, 0.22); + border-top-color: var(--accent-soft); + border-radius: 999px; + animation: equipmentFrameSpin 900ms linear infinite; +} + +.equipment-frame { + width: 100%; + height: 100%; + border: 0; + background: #05080d; +} + +@keyframes equipmentFrameSpin { + to { + transform: rotate(360deg); + } +} diff --git a/src/styles/detail/indicators.css b/src/styles/detail/indicators.css new file mode 100644 index 0000000..5390fed --- /dev/null +++ b/src/styles/detail/indicators.css @@ -0,0 +1,78 @@ +.indicator-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: min(560px, 100%); +} + +.indicator-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.indicator-summary div { + position: relative; + overflow: hidden; + min-height: 76px; + padding: 13px; + border: 1px solid rgba(88, 103, 121, 0.46); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 34%), + repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.024) 0 1px, transparent 1px 10px), + rgba(2, 6, 23, 0.34); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + inset 0 -1px 0 rgba(0, 0, 0, 0.48); +} + +.indicator-summary div::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: rgba(212, 165, 116, 0.58); +} + +.indicator-summary span { + display: block; + color: #9aa8ba; + font-size: 0.74rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.indicator-summary strong { + display: block; + margin-top: 9px; + color: #f8fafc; + font-size: 1.65rem; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +@media (max-width: 1200px) { + .indicator-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .indicator-controls { + min-width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 680px) { + .indicator-summary { + grid-template-columns: 1fr; + } + + .indicator-controls { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/styles/detail/overview.css b/src/styles/detail/overview.css new file mode 100644 index 0000000..b3a5bad --- /dev/null +++ b/src/styles/detail/overview.css @@ -0,0 +1,81 @@ +.detail-overview { + display: grid; + gap: 18px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.summary-card { + min-height: 149px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(10, 13, 18, 0.62); +} + +.summary-card span { + display: block; + color: var(--muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.summary-card strong { + display: block; + margin-top: 18px; + font-size: clamp(1.5rem, 3vw, 2.3rem); + line-height: 1.1; +} + +.detail-action-panel { + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(10, 13, 18, 0.58); +} + +.detail-action-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; +} + +.detail-action-grid button { + min-height: 92px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(2, 6, 23, 0.28); + color: #cbd5e1; + cursor: pointer; +} + +.detail-action-grid button:hover { + border-color: var(--line-strong); + background: rgba(201, 122, 61, 0.12); + color: var(--text); +} + +@media (max-width: 1200px) { + .summary-grid, + .detail-action-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .summary-grid, + .detail-action-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/utils/historyGranularity.ts b/src/utils/historyGranularity.ts new file mode 100644 index 0000000..f008f35 --- /dev/null +++ b/src/utils/historyGranularity.ts @@ -0,0 +1,62 @@ +export type HistoryAxisLabelFormat = + | 'time-seconds' + | 'time-minutes' + | 'hour-zero' + | 'day-hour' + | 'weekday-day' + | 'day-month' + | 'day-month-name' + | 'month-name' + | 'month-year' + | 'quarter-year' + | 'year'; + +export type HistoryGranularity = { + granulate: string; + tickIntervalMs: number; + labelFormat: HistoryAxisLabelFormat; +}; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; +const MONTH = 30 * DAY; +const YEAR = 365 * DAY; + +type Rule = { + maxMs: number; + granulate: string; + tickIntervalMs: number; + labelFormat: HistoryAxisLabelFormat; +}; + +const RULES: Rule[] = [ + { maxMs: 15 * MINUTE, granulate: '1 second', tickIntervalMs: MINUTE, labelFormat: 'time-seconds' }, + { maxMs: HOUR, granulate: '5 seconds', tickIntervalMs: 5 * MINUTE, labelFormat: 'time-minutes' }, + { maxMs: 3 * HOUR, granulate: '10 seconds', tickIntervalMs: 15 * MINUTE, labelFormat: 'time-minutes' }, + { maxMs: 6 * HOUR, granulate: '30 seconds', tickIntervalMs: 30 * MINUTE, labelFormat: 'time-minutes' }, + { maxMs: 12 * HOUR, granulate: '1 minute', tickIntervalMs: HOUR, labelFormat: 'hour-zero' }, + { maxMs: 3 * DAY, granulate: '5 minutes', tickIntervalMs: 6 * HOUR, labelFormat: 'day-hour' }, + { maxMs: 7 * DAY, granulate: '10 minutes', tickIntervalMs: DAY, labelFormat: 'weekday-day' }, + { maxMs: 10 * DAY, granulate: '15 minutes', tickIntervalMs: DAY, labelFormat: 'day-month' }, + { maxMs: 3 * WEEK, granulate: '30 minutes', tickIntervalMs: 2 * DAY, labelFormat: 'day-month' }, + { maxMs: 6 * WEEK, granulate: '1 hour', tickIntervalMs: WEEK, labelFormat: 'day-month' }, + { maxMs: 4 * MONTH, granulate: '3 hours', tickIntervalMs: 2 * WEEK, labelFormat: 'day-month-name' }, + { maxMs: 8 * MONTH, granulate: '6 hours', tickIntervalMs: MONTH, labelFormat: 'month-name' }, + { maxMs: 1.5 * YEAR, granulate: '12 hours', tickIntervalMs: MONTH, labelFormat: 'month-year' }, + { maxMs: 3 * YEAR, granulate: '1 day', tickIntervalMs: 3 * MONTH, labelFormat: 'quarter-year' }, +]; + +/** Подбирает шаг данных и подписи X по длительности выбранного диапазона. */ +export function getHistoryGranularity(from: string, to: string): HistoryGranularity { + const spanMs = new Date(to).getTime() - new Date(from).getTime(); + return ( + RULES.find((rule) => spanMs <= rule.maxMs) ?? { + granulate: '1 week', + tickIntervalMs: YEAR, + labelFormat: 'year', + } + ); +} diff --git a/src/utils/metricStatus.ts b/src/utils/metricStatus.ts index a7e4cca..dd50510 100644 --- a/src/utils/metricStatus.ts +++ b/src/utils/metricStatus.ts @@ -1,4 +1,4 @@ -import type { CurrentItem } from '../api/cloud'; +import type { CurrentItem } from '../entities/current/types'; export type MetricStatus = 'normal' | 'warning' | 'critical'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f9bfc2e..0b9d1cd 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_CLOUD_API_URL?: string; + readonly VITE_DIAGRAM_API_URL?: string; readonly VITE_TOIR_LIGHT_ORIGIN?: string; readonly VITE_KEYCLOAK_URL?: string; readonly VITE_KEYCLOAK_REALM?: string; From 61ab9eb37a41f84bdf3e471872e13fb8ba509482 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: Sun, 21 Jun 2026 03:08:35 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20rollupOpti?= =?UTF-8?q?ons=20=D0=B8=D0=B7=20vite.config.=20=D0=A3=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20equipments.=20=D0=A1=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20route.=20?= =?UTF-8?q?=D0=94=D0=B5=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=B7=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=20EdgeDetailPage.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 18 +-- src/features/current/useCurrentQuery.ts | 11 -- src/features/edge-detail/EdgeCurrentPage.tsx | 49 ++++++ src/features/edge-detail/EdgeDetailPage.tsx | 142 +++--------------- src/features/edge-detail/EdgeHistoryPage.tsx | 96 ++++++++++++ .../edge-detail/components/EdgePageLayout.tsx | 53 +++++++ .../edge-detail/components/EdgeSidebar.tsx | 10 +- .../edge-detail/components/EquipmentView.tsx | 29 ---- .../edge-detail/components/OverviewView.tsx | 14 +- src/features/edge-detail/types.ts | 2 +- .../edges-dashboard/EdgesDashboard.tsx | 12 +- src/features/history/useHistoryQuery.ts | 28 ---- src/pages/EdgeDetailPage.tsx | 3 +- src/styles/detail.css | 1 - src/styles/detail/equipment.css | 57 ------- src/styles/layout.css | 11 -- vite.config.ts | 19 --- 17 files changed, 238 insertions(+), 317 deletions(-) delete mode 100644 src/features/current/useCurrentQuery.ts create mode 100644 src/features/edge-detail/EdgeCurrentPage.tsx create mode 100644 src/features/edge-detail/EdgeHistoryPage.tsx create mode 100644 src/features/edge-detail/components/EdgePageLayout.tsx delete mode 100644 src/features/edge-detail/components/EquipmentView.tsx delete mode 100644 src/features/history/useHistoryQuery.ts delete mode 100644 src/styles/detail/equipment.css diff --git a/src/app/router.tsx b/src/app/router.tsx index 419a2c5..0878910 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,16 +1,13 @@ import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom'; +import { EdgeCurrentPage } from '../features/edge-detail/EdgeCurrentPage'; import { EdgeDetailPage } from '../features/edge-detail/EdgeDetailPage'; +import { EdgeHistoryPage } from '../features/edge-detail/EdgeHistoryPage'; import { EdgesDashboard } from '../features/edges-dashboard/EdgesDashboard'; function DashboardRoute() { const navigate = useNavigate(); - return ( - navigate(`/edges/${encodeURIComponent(edgeId)}`)} - onOpenEquipment={(edgeId) => navigate(`/edges/${encodeURIComponent(edgeId)}/equipment`)} - /> - ); + return navigate(`/edges/${encodeURIComponent(edgeId)}`)} />; } export function AppRouter() { @@ -19,12 +16,9 @@ export function AppRouter() { } /> } /> - } /> - } /> - } /> - } /> - {/* Электросхемы временно скрыты до готовности опубликованных схем и diagram-service. */} - {/* } /> */} + } /> + } /> + } /> } /> diff --git a/src/features/current/useCurrentQuery.ts b/src/features/current/useCurrentQuery.ts deleted file mode 100644 index bb13c02..0000000 --- a/src/features/current/useCurrentQuery.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getCurrent } from '../../entities/current/api'; - -export function useCurrentQuery(edgeId: string, eventsConnected: boolean) { - return useQuery({ - queryKey: ['current', edgeId], - queryFn: () => getCurrent(edgeId), - enabled: Boolean(edgeId), - refetchInterval: eventsConnected ? false : 1_000, - }); -} diff --git a/src/features/edge-detail/EdgeCurrentPage.tsx b/src/features/edge-detail/EdgeCurrentPage.tsx new file mode 100644 index 0000000..d095795 --- /dev/null +++ b/src/features/edge-detail/EdgeCurrentPage.tsx @@ -0,0 +1,49 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'react-router-dom'; +import { getCurrent } from '../../entities/current/api'; +import { createCurrentTagLabels, filterCurrentItems } from '../current/model'; +import { useCurrentEvents } from '../current/useCurrentEvents'; +import { EdgePageLayout } from './components/EdgePageLayout'; +import { IndicatorsView } from './components/IndicatorsView'; + +export function EdgeCurrentPage() { + const { edgeId = '' } = useParams(); + const [search, setSearch] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); + const currentEventsConnected = useCurrentEvents(edgeId); + const current = useQuery({ + queryKey: ['current', edgeId], + queryFn: () => getCurrent(edgeId), + enabled: Boolean(edgeId), + refetchInterval: currentEventsConnected ? false : 1_000, + }); + const currentItems = useMemo(() => current.data?.items ?? [], [current.data?.items]); + const tagLabels = useMemo(() => createCurrentTagLabels(currentItems), [currentItems]); + const getTagLabel = (tag: string) => tagLabels[tag] ?? tag; + const visibleItems = useMemo(() => filterCurrentItems(currentItems, search), [currentItems, search]); + + const toggleTag = (tag: string) => { + setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((selected) => selected !== tag) : [...prev, tag])); + }; + + return ( + void current.refetch()} + > + + + ); +} diff --git a/src/features/edge-detail/EdgeDetailPage.tsx b/src/features/edge-detail/EdgeDetailPage.tsx index 7a156f6..3d15514 100644 --- a/src/features/edge-detail/EdgeDetailPage.tsx +++ b/src/features/edge-detail/EdgeDetailPage.tsx @@ -1,137 +1,45 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAuth } from '../../auth/authContext'; +import { getCurrent } from '../../entities/current/api'; import { countLiveCurrentItems, - createCurrentTagLabels, - filterCurrentItems, getLatestCurrentUpdatedAt, } from '../current/model'; import { useCurrentEvents } from '../current/useCurrentEvents'; -import { useCurrentQuery } from '../current/useCurrentQuery'; -import { createRange } from '../history/dateRange'; -import { useHistoryQuery } from '../history/useHistoryQuery'; -import { ArchiveView } from './components/ArchiveView'; -import { EdgeSidebar } from './components/EdgeSidebar'; -import { EdgeTopbar } from './components/EdgeTopbar'; -import { EquipmentView } from './components/EquipmentView'; -import { IndicatorsView } from './components/IndicatorsView'; +import { EdgePageLayout } from './components/EdgePageLayout'; import { OverviewView } from './components/OverviewView'; -import type { DetailView } from './types'; -type EdgeDetailPageProps = { - view: DetailView; -}; - -/** Собирает раздел выбранной буровой из вкладок текущих данных, архива и оборудования. */ -export function EdgeDetailPage({ view }: EdgeDetailPageProps) { +export function EdgeDetailPage() { const navigate = useNavigate(); - const auth = useAuth(); const { edgeId = '' } = useParams(); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [search, setSearch] = useState(''); - const [range, setRange] = useState(() => createRange(24)); - const [selectedTags, setSelectedTags] = useState([]); - const edgePath = `/edges/${encodeURIComponent(edgeId)}`; const currentEventsConnected = useCurrentEvents(edgeId); - const current = useCurrentQuery(edgeId, currentEventsConnected); + const current = useQuery({ + queryKey: ['current', edgeId], + queryFn: () => getCurrent(edgeId), + enabled: Boolean(edgeId), + refetchInterval: currentEventsConnected ? false : 1_000, + }); const currentItems = useMemo(() => current.data?.items ?? [], [current.data?.items]); - - const tagLabels = useMemo(() => createCurrentTagLabels(currentItems), [currentItems]); - const getTagLabel = (tag: string) => tagLabels[tag] ?? tag; - const visibleItems = useMemo(() => filterCurrentItems(currentItems, search), [currentItems, search]); const latestUpdatedAt = useMemo(() => getLatestCurrentUpdatedAt(currentItems), [currentItems]); const liveCount = countLiveCurrentItems(currentItems); - const { query: history, granularity: historyGranularity } = useHistoryQuery(edgeId, selectedTags[0], range); - - const toggleTag = (tag: string) => { - setSelectedTags((prev) => (prev.includes(tag) ? [] : [tag])); - }; - - const selectFirstTags = () => { - setSelectedTags(visibleItems[0] ? [visibleItems[0].tag] : []); - }; - - const selectVisibleTags = () => { - setSelectedTags(visibleItems[0] ? [visibleItems[0].tag] : []); - }; - - const clearVisibleTags = () => { - const visibleTags = new Set(visibleItems.map((item) => item.tag)); - setSelectedTags((prev) => prev.filter((tag) => !visibleTags.has(tag))); - }; return ( -
- setSidebarCollapsed((collapsed) => !collapsed)} + void current.refetch()} + > + navigate(`${edgePath}/archive`)} + onOpenIndicators={() => navigate(`${edgePath}/indicators`)} /> - -
- navigate('/edges')} - onLogout={() => void auth.logout()} - onRefresh={() => void current.refetch()} - /> - - {view === 'overview' ? ( - navigate(`${edgePath}/archive`)} - onOpenIndicators={() => navigate(`${edgePath}/indicators`)} - onOpenEquipment={() => navigate(`${edgePath}/equipment`)} - /> - ) : null} - - {view === 'archive' ? ( - 0} - historyGranulate={history.data?.granulate ?? historyGranularity.granulate} - historyAxis={historyGranularity} - range={range} - onSearchChange={setSearch} - onRangeChange={setRange} - onToggleTag={toggleTag} - onSelectFirstTags={selectFirstTags} - onSelectVisibleTags={selectVisibleTags} - onClearVisibleTags={clearVisibleTags} - onClearTags={() => setSelectedTags([])} - getTagLabel={getTagLabel} - tagLabels={tagLabels} - /> - ) : null} - - {view === 'indicators' ? ( - - ) : null} - - {view === 'equipment' ? : null} -
-
+ ); } diff --git a/src/features/edge-detail/EdgeHistoryPage.tsx b/src/features/edge-detail/EdgeHistoryPage.tsx new file mode 100644 index 0000000..4c79391 --- /dev/null +++ b/src/features/edge-detail/EdgeHistoryPage.tsx @@ -0,0 +1,96 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'react-router-dom'; +import { getCurrent } from '../../entities/current/api'; +import { getHistory } from '../../entities/history/api'; +import { toIsoFromInput } from '../../utils/format'; +import { getHistoryGranularity } from '../../utils/historyGranularity'; +import { createCurrentTagLabels, filterCurrentItems } from '../current/model'; +import { useCurrentEvents } from '../current/useCurrentEvents'; +import { createRange } from '../history/dateRange'; +import { ArchiveView } from './components/ArchiveView'; +import { EdgePageLayout } from './components/EdgePageLayout'; + +export function EdgeHistoryPage() { + const { edgeId = '' } = useParams(); + const [search, setSearch] = useState(''); + const [range, setRange] = useState(() => createRange(24)); + const [selectedTags, setSelectedTags] = useState([]); + const currentEventsConnected = useCurrentEvents(edgeId); + const current = useQuery({ + queryKey: ['current', edgeId], + queryFn: () => getCurrent(edgeId), + enabled: Boolean(edgeId), + refetchInterval: currentEventsConnected ? false : 1_000, + }); + const currentItems = useMemo(() => current.data?.items ?? [], [current.data?.items]); + const tagLabels = useMemo(() => createCurrentTagLabels(currentItems), [currentItems]); + const getTagLabel = (tag: string) => tagLabels[tag] ?? tag; + const visibleItems = useMemo(() => filterCurrentItems(currentItems, search), [currentItems, search]); + const from = toIsoFromInput(range.from) as string; + const to = toIsoFromInput(range.to) as string; + const historyGranularity = useMemo(() => getHistoryGranularity(from, to), [from, to]); + const selectedTag = selectedTags[0]; + const history = useQuery({ + queryKey: ['history', edgeId, selectedTag, from, to, historyGranularity.granulate], + queryFn: () => + getHistory({ + edge: edgeId, + tag: selectedTag as string, + from, + to, + granulate: historyGranularity.granulate, + }), + enabled: Boolean(edgeId && selectedTag), + refetchInterval: false, + }); + + const toggleTag = (tag: string) => { + setSelectedTags((prev) => (prev.includes(tag) ? [] : [tag])); + }; + + const selectFirstTags = () => { + setSelectedTags(visibleItems[0] ? [visibleItems[0].tag] : []); + }; + + const selectVisibleTags = () => { + setSelectedTags(visibleItems[0] ? [visibleItems[0].tag] : []); + }; + + const clearVisibleTags = () => { + const visibleTags = new Set(visibleItems.map((item) => item.tag)); + setSelectedTags((prev) => prev.filter((tag) => !visibleTags.has(tag))); + }; + + return ( + { + void current.refetch(); + void history.refetch(); + }} + > + 0} + historyGranulate={history.data?.granulate ?? historyGranularity.granulate} + historyAxis={historyGranularity} + range={range} + onSearchChange={setSearch} + onRangeChange={setRange} + onToggleTag={toggleTag} + onSelectFirstTags={selectFirstTags} + onSelectVisibleTags={selectVisibleTags} + onClearVisibleTags={clearVisibleTags} + onClearTags={() => setSelectedTags([])} + getTagLabel={getTagLabel} + tagLabels={tagLabels} + /> + + ); +} diff --git a/src/features/edge-detail/components/EdgePageLayout.tsx b/src/features/edge-detail/components/EdgePageLayout.tsx new file mode 100644 index 0000000..dd3ef1d --- /dev/null +++ b/src/features/edge-detail/components/EdgePageLayout.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../../auth/authContext'; +import { EdgeSidebar } from './EdgeSidebar'; +import { EdgeTopbar } from './EdgeTopbar'; +import type { DetailView } from '../types'; + +type EdgePageLayoutProps = { + children: ReactNode; + currentEventsConnected: boolean; + edgeId: string; + onRefresh: () => void; + view: DetailView; +}; + +export function EdgePageLayout({ + children, + currentEventsConnected, + edgeId, + onRefresh, + view, +}: EdgePageLayoutProps) { + const navigate = useNavigate(); + const auth = useAuth(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const edgePath = `/edges/${encodeURIComponent(edgeId)}`; + + return ( +
+ setSidebarCollapsed((collapsed) => !collapsed)} + /> + +
+ navigate('/edges')} + onLogout={() => void auth.logout()} + onRefresh={onRefresh} + /> + + {children} +
+
+ ); +} diff --git a/src/features/edge-detail/components/EdgeSidebar.tsx b/src/features/edge-detail/components/EdgeSidebar.tsx index c529085..c7d7b7f 100644 --- a/src/features/edge-detail/components/EdgeSidebar.tsx +++ b/src/features/edge-detail/components/EdgeSidebar.tsx @@ -1,4 +1,4 @@ -import { Activity, BarChart3, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Wrench } from 'lucide-react'; +import { Activity, BarChart3, Gauge, Menu, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import type { DetailView } from '../types'; type EdgeSidebarProps = { @@ -56,14 +56,6 @@ export function EdgeSidebar({ collapsed, edgePath, view, onNavigate, onToggleCol Показатели - ); diff --git a/src/features/edge-detail/components/EquipmentView.tsx b/src/features/edge-detail/components/EquipmentView.tsx deleted file mode 100644 index 862cd20..0000000 --- a/src/features/edge-detail/components/EquipmentView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useState } from 'react'; -import { ToirLightIframe } from '../../../components/ToirLightIframe'; - -const ACTIVE_EQUIPMENT_PATH = - '/equipment?filter=%7B%22status%22%3A%5B%22Active%22%5D%7D&displayedFilters=%7B%22status%22%3Atrue%7D'; - -export function EquipmentView() { - const [iframeLoaded, setIframeLoaded] = useState(false); - - return ( -
-
- {!iframeLoaded ? ( -
- - Загрузка интерфейса управления оборудованием... -
- ) : null} - - setIframeLoaded(true)} - /> -
-
- ); -} diff --git a/src/features/edge-detail/components/OverviewView.tsx b/src/features/edge-detail/components/OverviewView.tsx index 7ca83aa..6627dbd 100644 --- a/src/features/edge-detail/components/OverviewView.tsx +++ b/src/features/edge-detail/components/OverviewView.tsx @@ -1,14 +1,12 @@ -import { Activity, BarChart3, CalendarClock, Wrench } from 'lucide-react'; +import { Activity, BarChart3, CalendarClock } from 'lucide-react'; import { formatDateTime } from '../../../utils/format'; type OverviewViewProps = { edgeId: string; latestUpdatedAt?: Date; liveCount: number; - selectedCount: number; totalTags: number; onOpenArchive: () => void; - onOpenEquipment: () => void; onOpenIndicators: () => void; }; @@ -16,10 +14,8 @@ export function OverviewView({ edgeId, latestUpdatedAt, liveCount, - selectedCount, totalTags, onOpenArchive, - onOpenEquipment, onOpenIndicators, }: OverviewViewProps) { return ( @@ -34,8 +30,8 @@ export function OverviewView({ {liveCount}
- Выбрано для графика - {selectedCount} + Архив + 1
Последнее обновление @@ -57,10 +53,6 @@ export function OverviewView({ Архив и график - + onChange(event.target.value)} + /> +
+ ); +} + export function ArchiveView({ getTagLabel, history, @@ -49,6 +99,16 @@ export function ArchiveView({ const [selectorOpen, setSelectorOpen] = useState(false); const selectedPreview = selectedTags.slice(0, 10); const hiddenSelectedCount = Math.max(0, selectedTags.length - selectedPreview.length); + const fromDateRef = useRef(null); + const fromTimeRef = useRef(null); + const toDateRef = useRef(null); + const toTimeRef = useRef(null); + const updateRangePart = (rangePart: RangePart, inputPart: RangeInputPart, value: string) => { + onRangeChange({ + ...range, + [rangePart]: updateRangeInputPart(range[rangePart], inputPart, value), + }); + }; return (
@@ -148,22 +208,40 @@ export function ArchiveView({ ))} -