import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AdminApiClient } from "./api/client"; import type { AuditEvent, AuthResult, Cluster, ClusterAdminSummary, ClusterAuthorityState, ClusterNode, ClusterNodeGroup, CreatedJoinToken, FabricEntryPoint, FabricEntryPointNode, FabricEgressPool, FabricEgressPoolNode, FabricTestingFlag, InstallationStatus, JoinRequest, MeshLink, NodeHeartbeat, NodeSyntheticMeshConfig, NodeTelemetryObservation, NodeWorkloadDesiredState, OrganizationAdminSummary, QoSPolicy, RoleAssignment, VPNConnection, VPNConnectionLease, WorkloadStatus, } from "./types"; const storageKeys = { baseUrl: "rap.webAdmin.baseUrl", actorUserId: "rap.webAdmin.actorUserId", auth: "rap.webAdmin.auth", language: "rap.webAdmin.language", }; const defaultBaseUrl = "/api/v1"; const legacyDirectBackendPrefix = "http://192.168.200.61:8080/api/v1"; const roleOptions = [ "entry-node", "relay-node", "core-mesh", "rdp-worker", "vnc-worker", "vpn-exit", "vpn-connector", "file-storage-cache", "update-cache", "video-relay", ]; const capabilityKeysByRole: Record = { "entry-node": ["can_accept_client_ingress"], "relay-node": ["can_route_mesh", "can_accept_node_ingress"], "core-mesh": ["can_route_mesh", "can_accept_node_ingress"], "rdp-worker": ["can_run_rdp_worker"], "vnc-worker": ["can_run_vnc_worker"], "vpn-exit": ["can_run_vpn_exit"], "vpn-connector": ["can_run_vpn_connector"], "file-storage-cache": ["can_run_file_cache"], "update-cache": ["can_run_update_cache"], "video-relay": ["can_run_video_relay"], }; const views = [ { id: "command", ru: "Обзор", en: "Command" }, { id: "clusters", ru: "Кластеры", en: "Clusters" }, { id: "cluster-settings", ru: "Настройки кластера", en: "Cluster Settings" }, { id: "nodes", ru: "Узлы", en: "Nodes" }, { id: "enrollment", ru: "Подключение узлов", en: "Enrollment" }, { id: "roles", ru: "Роли", en: "Roles" }, { id: "workloads", ru: "Сервисы", en: "Workloads" }, { id: "fabric", ru: "Связи Fabric", en: "Fabric Links" }, { id: "vpn", ru: "VPN Control", en: "VPN Control" }, { id: "org-safe", ru: "Организации", en: "Organizations" }, { id: "audit", ru: "Аудит", en: "Audit" }, ] as const; type ViewId = (typeof views)[number]["id"]; type Language = "ru" | "en"; type NodeInventoryEntry = { node: ClusterNode; memberships: Array<{ cluster: Cluster; node: ClusterNode }>; }; type NodeInventoryTreeRow = | { kind: "group"; key: string; label: string; depth: number; count: number; groupId?: string } | { kind: "node"; key: string; entry: NodeInventoryEntry; depth: number }; type WebAdminSession = { userId: string; email: string; authSessionId: string; accessToken: string; refreshToken: string; accessTokenExpiresAt: string; refreshTokenExpiresAt: string; }; const copy = { ru: { productOwner: "Владелец продукта", controlPlane: "Панель управления", sideText: "Главная панель владельца платформы для кластеров, узлов, доверия, ролей и безопасного desired state.", signInTitle: "Вход", signInText: "Введите учетные данные.", bootstrapTitle: "Первый владелец", bootstrapText: "Пустая установка принимает только подписанную активацию продукта.", activationPayload: "Activation manifest JSON", activationSignature: "Подпись manifest", createOwner: "Создать владельца", creatingOwner: "Создание...", ownerCreated: "Владелец создан. Теперь можно войти.", installationLocked: "Установка уже активирована", insecureBootstrapDisabled: "Insecure bootstrap выключен. Нужна strict-активация с ключом продукта.", email: "Логин", password: "Пароль", backendApi: "Backend API", useLocalProxy: "Использовать локальный /api/v1 proxy", language: "Язык", deviceLabel: "Устройство", trustDevice: "Доверять этому устройству", signIn: "Войти", signingIn: "Вход...", logout: "Выйти", profile: "Профиль", refresh: "Обновить", refreshing: "Обновление...", activeCluster: "Активный кластер", slugLabel: "Технический код", slugHelp: "Slug — постоянный короткий технический идентификатор кластера для URL, скриптов, логов и интеграций. Его лучше не менять после создания.", clusterCatalog: "Каталог кластеров", clusterCatalogText: "Список реальных кластеров из Control Plane. Выберите активный кластер или раскройте карточку для подробностей.", makeActive: "Сделать активным", openSettings: "Открыть настройки", selected: "Выбран", createCluster: "Создать кластер", clusterDetails: "Подробнее", consoleTitle: "Панель владельца платформы", boundary: "WEB является только представлением. Решения кластера проходят через Control Plane API, PostgreSQL как source of truth и аудит.", noLoginError: "Войдите как владелец продукта или администратор платформы, чтобы загрузить панель.", accessDenied: "Доступ к этой панели запрещен.", emptyLiveTitle: "Кластер пока пустой", emptyLiveText: "Это реальные данные, не заглушка: в выбранном кластере ещё нет одобренных node-agent узлов. Создайте join token, запустите rap-node-agent и подтвердите join request.", realDataNote: "Показываются только данные из PostgreSQL/Control Plane. Если значения нулевые, значит соответствующих узлов, ролей или сервисов пока нет.", signedInAs: "Вход выполнен", actorUser: "Actor user", testMode: "Тестирование", testModeText: "Включает тестовую телеметрию и синтетические наблюдения связей. Это не production mesh runtime.", platformTestFlag: "Тестирование сервера", nodeTelemetry: "Телеметрия узла", heartbeatHistory: "История heartbeat", noTelemetry: "Телеметрии пока нет", enableTelemetry: "Включить телеметрию", enableSyntheticLinks: "Включить тестовые связи", saveTestFlag: "Сохранить флаг", nodeManagement: "Управление узлом", nodeScope: "Область просмотра", currentClusterNodes: "Узлы активного кластера", allNodes: "Все узлы платформы", showAllPlatformNodes: "Показать все узлы платформы", currentClusterMembership: "Участие в активном кластере", clusterMemberships: "Участие по кластерам", notMemberOfActiveCluster: "не состоит", nodeIdentity: "Физическая идентичность узла", activeClusterScope: "Область активного кластера", activeClusterScopeText: "Один физический узел может состоять в нескольких кластерах. Роли и desired-сервисы ниже относятся только к выбранному активному кластеру.", capabilityConfirmed: "способность подтверждена heartbeat", capabilityMissing: "способность не заявлена узлом", capabilityUnknown: "способность не подтверждена: нет heartbeat", nodeGlobalInventoryText: "Один физический узел показан один раз. Участие и роли остаются кластерными: в разных кластерах этот же узел может иметь разные назначения.", nodeSearch: "Поиск узлов", groupNodesBy: "Группировать", groupByMembership: "по участию", groupByHealth: "по здоровью", groupByOwnership: "по владению", groupByClusterCount: "по числу кластеров", nodeGroups: "Группы узлов", nodeGroupTree: "Дерево групп", nodeGroupFilter: "Фильтр по группе", allNodeGroups: "Все группы", nodeGroupCreatePanel: "Создание группы", nodeGroupName: "Название группы", parentNodeGroup: "Родительская группа", rootNodeGroup: "Корень", ungroupedNodes: "Без группы", createNodeGroup: "Создать группу", createSubgroup: "Создать подгруппу", collapseGroup: "Свернуть", expandGroup: "Развернуть", assignNodeGroup: "Переместить в группу", removeFromNodeGroup: "Убрать из группы", connectExistingNode: "Подключить к активному кластеру", connectExistingNodeTitle: "Подключить существующий узел", connectExistingNodeText: "Будет создано или повторно включено участие конкретного физического узла в активном кластере. Роли ниже назначаются только в этом кластере.", connectWithRoles: "Подключить с ролями", nodeDetails: "Сведения", manageNode: "Настроить", nodeFunctions: "Функции узла", nodeFunctionsText: "Одна строка управляет функцией целиком: роль задает разрешение в активном кластере, desired-сервис задает запуск, observed показывает факт от node-agent.", rolePermission: "Разрешение", permissionGranted: "разрешено", permissionDenied: "нет разрешения", organizationScopeForEnable: "Область организации для новых включений, опционально", clusterWideRolePlaceholder: "пусто = роль на весь кластер", desiredRuntime: "Желаемое состояние", observedRuntime: "Фактически", enableFunction: "Включить функцию", disableFunction: "Выключить функцию", close: "Закрыть", nodeBriefList: "Краткий список узлов", noActiveClusterMembership: "Узел не входит в активный кластер", nodeBriefListHelp: "Список сгруппирован деревом активного кластера. Полные сведения, управление, роли, сервисы и статистика открываются из строки узла.", nodeSearchPlaceholder: "имя, ключ, кластер, статус", nodeGroupInventoryText: "Группы — это кластерная инвентарная структура. Перенос узла в группу меняет только его размещение внутри активного кластера, не роли и не членство.", nodeGroupCreated: "Группа узлов создана.", noNodesTitle: "Нет узлов", noNodesByFilter: "По текущему фильтру узлы не найдены.", cancel: "Отмена", alreadyMember: "Уже в активном кластере", revokedMembership: "Участие отозвано", addNode: "Подключить узел", addNodeText: "Подключение существующего физического узла к активному кластеру выполняется из списка узлов: включите общий режим и нажмите «Подключить к активному кластеру».", joinTokenTitle: "Создать join token", joinTokenText: "Join token — временный пропуск для rap-node-agent. Он не создает узел напрямую: агент отправляет заявку, а владелец платформы подтверждает ее.", ttlHours: "Срок действия, часов", ttlHelp: "Через это время token станет недействительным, даже если им никто не воспользовался. Для ручного подключения обычно достаточно 1–24 часов.", maxUses: "Максимум использований", maxUsesHelp: "Сколько node-agent смогут использовать этот token. Самый безопасный вариант — 1 token на 1 новый узел.", tokenPurpose: "Назначение token", nodeOwnership: "Тип владения узлом", suggestedRoles: "Разрешенные/ожидаемые роли", generatedScope: "Сгенерированная область действия", generatedScopeHelp: "JSON формируется автоматически из настроек выше. Оператор не должен писать его руками, чтобы не ошибиться синтаксисом или областью доступа.", manualApprovalRequired: "Подтверждение заявки вручную обязательно", nodeRoles: "Роли узла", desiredServices: "Желаемые сервисы", observedServices: "Наблюдаемые сервисы", noRoles: "Ролей пока нет", noServices: "Сервисов пока нет", manageInCluster: "Управлять в кластере", rolesAndServices: "Роли и сервисы", links: "Связи", fabricMap: "Карта трафика Fabric", fabricIngressLayer: "Входы", fabricNodeLayer: "Узлы кластера", fabricEgressLayer: "Выходные зоны", observedPeerLinks: "Наблюдаемые связи", placementIntent: "control-plane назначение", fabricEntryPoints: "Точки входа", fabricEntryPointHelp: "Логические внешние входы в кластер. Они скрывают конкретные узлы от организаций и клиентов.", fabricEgressPools: "Выходные зоны", fabricEgressPoolHelp: "Логические выходы к внешним сетям, например “Офис Москва”. Организации используют зону, а не конкретный узел.", endpointName: "Название", publicEndpoint: "Публичный адрес", endpointType: "Тип входа", description: "Описание", routeScope: "Область маршрутов JSON", createEntryPoint: "Создать точку входа", createEgressPool: "Создать выходную зону", endpointNodes: "Назначенные узлы", assignEndpointNode: "Назначить узел", selectNode: "Выберите узел", assignedNodesEmpty: "Узлы пока не назначены", entryPointsEmpty: "Точки входа пока не созданы.", egressPoolsEmpty: "Выходные зоны пока не созданы.", addressNotSet: "адрес не задан", descriptionNotSet: "описание не задано", servicePlacement: "Размещение сервисов", trafficFlow: "Потоки между узлами", organizationTestFlag: "Тестирование организации", organizationId: "ID организации", saveOrganizationFlag: "Сохранить флаг организации", noLinks: "Связей пока нет", recentHeartbeats: "Последние heartbeat", memory: "Память", cpu: "Процессор", processes: "Процессы", }, en: { productOwner: "Product Owner", controlPlane: "Control Plane", sideText: "Full platform-owner panel for clusters, nodes, trust, placement, and safe service desired state.", signInTitle: "Sign in", signInText: "Enter your credentials.", bootstrapTitle: "First owner", bootstrapText: "An empty installation accepts only a signed product activation.", activationPayload: "Activation manifest JSON", activationSignature: "Manifest signature", createOwner: "Create owner", creatingOwner: "Creating...", ownerCreated: "Owner created. You can sign in now.", installationLocked: "Installation is already active", insecureBootstrapDisabled: "Insecure bootstrap is disabled. Strict product-key activation is required.", email: "Login", password: "Password", backendApi: "Backend API", useLocalProxy: "Use local /api/v1 proxy", language: "Language", deviceLabel: "Device", trustDevice: "Trust this device", signIn: "Sign in", signingIn: "Signing in...", logout: "Logout", profile: "Profile", refresh: "Refresh", refreshing: "Refreshing...", activeCluster: "Active cluster", slugLabel: "Technical code", slugHelp: "Slug is a stable short technical identifier for URLs, scripts, logs, and integrations. It should generally not change after creation.", clusterCatalog: "Cluster catalog", clusterCatalogText: "Real clusters from the Control Plane. Select the active cluster or expand a card for details.", makeActive: "Make active", openSettings: "Open settings", selected: "Selected", createCluster: "Create cluster", clusterDetails: "Details", consoleTitle: "Platform Owner Console", boundary: "WEB is presentation only. Cluster decisions go through Control Plane APIs, PostgreSQL source of truth, and audit.", noLoginError: "Sign in as a product owner or platform administrator to load the panel.", accessDenied: "Access to this panel is denied.", emptyLiveTitle: "Cluster has no live nodes yet", emptyLiveText: "These are real values, not placeholders: the selected cluster has no approved node-agent nodes yet. Create a join token, run rap-node-agent, and approve the join request.", realDataNote: "Only PostgreSQL/Control Plane data is shown. Zero values mean the corresponding nodes, roles, or services do not exist yet.", signedInAs: "Signed in", actorUser: "Actor user", testMode: "Testing", testModeText: "Enables test telemetry and synthetic link observations. This is not production mesh runtime.", platformTestFlag: "Server testing", nodeTelemetry: "Node telemetry", heartbeatHistory: "Heartbeat history", noTelemetry: "No telemetry yet", enableTelemetry: "Enable telemetry", enableSyntheticLinks: "Enable test links", saveTestFlag: "Save flag", nodeManagement: "Node management", nodeScope: "View scope", currentClusterNodes: "Active cluster nodes", allNodes: "All platform nodes", showAllPlatformNodes: "Show all platform nodes", currentClusterMembership: "Active cluster membership", clusterMemberships: "Cluster memberships", notMemberOfActiveCluster: "not a member", nodeIdentity: "Physical node identity", activeClusterScope: "Active cluster scope", activeClusterScopeText: "One physical node may belong to multiple clusters. Roles and desired services below belong only to the selected active cluster.", capabilityConfirmed: "capability confirmed by heartbeat", capabilityMissing: "capability not reported by node", capabilityUnknown: "capability unconfirmed: no heartbeat", nodeGlobalInventoryText: "Each physical node is shown once. Membership and roles remain cluster-scoped, so the same node may have different assignments in different clusters.", nodeSearch: "Node search", groupNodesBy: "Group by", groupByMembership: "membership", groupByHealth: "health", groupByOwnership: "ownership", groupByClusterCount: "cluster count", nodeGroups: "Node groups", nodeGroupTree: "Group tree", nodeGroupFilter: "Group filter", allNodeGroups: "All groups", nodeGroupCreatePanel: "Create group", nodeGroupName: "Group name", parentNodeGroup: "Parent group", rootNodeGroup: "Root", ungroupedNodes: "Ungrouped", createNodeGroup: "Create group", createSubgroup: "Create subgroup", collapseGroup: "Collapse", expandGroup: "Expand", assignNodeGroup: "Move to group", removeFromNodeGroup: "Remove from group", connectExistingNode: "Connect to active cluster", connectExistingNodeTitle: "Connect existing node", connectExistingNodeText: "This creates or re-enables membership for one concrete physical node in the active cluster. Roles below are assigned only in this cluster.", connectWithRoles: "Connect with roles", nodeDetails: "Details", manageNode: "Configure", nodeFunctions: "Node functions", nodeFunctionsText: "One row controls the whole function: role grants permission in the active cluster, desired service requests runtime start, observed state reports node-agent facts.", rolePermission: "Permission", permissionGranted: "granted", permissionDenied: "not allowed", organizationScopeForEnable: "Organization scope for new enables, optional", clusterWideRolePlaceholder: "empty = cluster-wide role", desiredRuntime: "Desired state", observedRuntime: "Observed", enableFunction: "Enable function", disableFunction: "Disable function", close: "Close", nodeBriefList: "Compact node list", noActiveClusterMembership: "Node is not a member of the active cluster", nodeBriefListHelp: "The list is grouped as the active cluster tree. Full details, management, roles, services, and statistics open from the node row.", nodeSearchPlaceholder: "name, key, cluster, status", nodeGroupInventoryText: "Groups are a cluster inventory structure. Moving a node to a group changes only its placement inside the active cluster, not roles or membership.", nodeGroupCreated: "Node group created.", noNodesTitle: "No nodes", noNodesByFilter: "No nodes match the current filter.", cancel: "Cancel", alreadyMember: "Already in active cluster", revokedMembership: "Membership revoked", addNode: "Add node", addNodeText: "Connect an existing physical node to the active cluster from the node list: enable platform-wide view and click “Connect to active cluster”.", joinTokenTitle: "Create join token", joinTokenText: "A join token is a temporary pass for rap-node-agent. It does not create a node directly: the agent submits a request and the platform owner approves it.", ttlHours: "Lifetime, hours", ttlHelp: "After this time the token becomes invalid even if unused. For manual enrollment, 1–24 hours is usually enough.", maxUses: "Maximum uses", maxUsesHelp: "How many node-agents may use this token. The safest default is one token for one new node.", tokenPurpose: "Token purpose", nodeOwnership: "Node ownership type", suggestedRoles: "Allowed/expected roles", generatedScope: "Generated scope", generatedScopeHelp: "JSON is generated automatically from the settings above. Operators should not hand-write it and risk syntax or access-scope mistakes.", manualApprovalRequired: "Manual request approval is required", nodeRoles: "Node roles", desiredServices: "Desired services", observedServices: "Observed services", noRoles: "No roles yet", noServices: "No services yet", manageInCluster: "Manage in cluster", rolesAndServices: "Roles and services", links: "Links", fabricMap: "Fabric traffic map", fabricIngressLayer: "Ingress", fabricNodeLayer: "Cluster nodes", fabricEgressLayer: "Egress pools", observedPeerLinks: "Observed links", placementIntent: "control-plane placement", fabricEntryPoints: "Entry points", fabricEntryPointHelp: "Logical external ingress points for the cluster. They hide concrete nodes from organizations and clients.", fabricEgressPools: "Egress pools", fabricEgressPoolHelp: "Logical exits to external networks, for example “Office Moscow”. Organizations use the pool, not a concrete node.", endpointName: "Name", publicEndpoint: "Public endpoint", endpointType: "Entry type", description: "Description", routeScope: "Route scope JSON", createEntryPoint: "Create entry point", createEgressPool: "Create egress pool", endpointNodes: "Assigned nodes", assignEndpointNode: "Assign node", selectNode: "Select node", assignedNodesEmpty: "No nodes assigned yet", entryPointsEmpty: "No entry points created yet.", egressPoolsEmpty: "No egress pools created yet.", addressNotSet: "address not set", descriptionNotSet: "description not set", servicePlacement: "Service placement", trafficFlow: "Node traffic flows", organizationTestFlag: "Organization testing", organizationId: "Organization ID", saveOrganizationFlag: "Save organization flag", noLinks: "No links yet", recentHeartbeats: "Recent heartbeats", memory: "Memory", cpu: "CPU", processes: "Processes", }, } satisfies Record>; function normalizeAuthResult(result: AuthResult): WebAdminSession { const userId = result.user.id || result.user.ID || ""; const email = result.user.email || result.user.Email || ""; const authSessionId = result.auth_session.id || result.auth_session.ID || ""; return { userId, email, authSessionId, accessToken: result.tokens.access_token, refreshToken: result.tokens.refresh_token, accessTokenExpiresAt: result.tokens.access_token_expires_at, refreshTokenExpiresAt: result.tokens.refresh_token_expires_at, }; } export function App() { const [baseUrl] = useState(() => { const stored = localStorage.getItem(storageKeys.baseUrl)?.trim(); return !stored || stored.startsWith(legacyDirectBackendPrefix) ? defaultBaseUrl : stored; }); const [session, setSession] = useState(null); const [language, setLanguage] = useState(() => (localStorage.getItem(storageKeys.language) === "en" ? "en" : "ru")); const [actorUserId, setActorUserId] = useState(""); const [loginForm, setLoginForm] = useState({ email: "", password: "", deviceLabel: "Панель владельца платформы", trustDevice: true, }); const [installationStatus, setInstallationStatus] = useState(null); const [bootstrapForm, setBootstrapForm] = useState({ email: "", password: "", activationPayload: "", activationSignature: "", }); const [activeView, setActiveView] = useState("command"); const [selectedClusterId, setSelectedClusterId] = useState(""); const [clusters, setClusters] = useState([]); const [clusterSummaries, setClusterSummaries] = useState([]); const [authority, setAuthority] = useState(null); const [nodes, setNodes] = useState([]); const [nodeGroups, setNodeGroups] = useState([]); const [allNodesByCluster, setAllNodesByCluster] = useState>({}); const [joinRequests, setJoinRequests] = useState([]); const [rolesByNode, setRolesByNode] = useState>({}); const [desiredWorkloadsByNode, setDesiredWorkloadsByNode] = useState>({}); const [workloadsByNode, setWorkloadsByNode] = useState>({}); const [heartbeatsByNode, setHeartbeatsByNode] = useState>({}); const [telemetryByNode, setTelemetryByNode] = useState>({}); const [meshLinks, setMeshLinks] = useState([]); const [syntheticMeshConfigsByNode, setSyntheticMeshConfigsByNode] = useState>({}); const [qosPolicies, setQosPolicies] = useState([]); const [entryPoints, setEntryPoints] = useState([]); const [entryPointNodesById, setEntryPointNodesById] = useState>({}); const [egressPools, setEgressPools] = useState([]); const [egressPoolNodesById, setEgressPoolNodesById] = useState>({}); const [testingFlags, setTestingFlags] = useState([]); const [vpnConnections, setVPNConnections] = useState([]); const [vpnLeases, setVPNLeases] = useState>({}); const [audit, setAudit] = useState([]); const [organizationId, setOrganizationId] = useState(""); const [organizationSummary, setOrganizationSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [clusterForm, setClusterForm] = useState({ slug: "", name: "", region: "" }); const [clusterSettingsForm, setClusterSettingsForm] = useState({ name: "", status: "active", region: "", metadataJson: "{}" }); const [nodeGroupForm, setNodeGroupForm] = useState({ name: "", parentGroupId: "" }); const [entryPointForm, setEntryPointForm] = useState({ name: "", endpointType: "client_access", publicEndpoint: "" }); const [egressPoolForm, setEgressPoolForm] = useState({ name: "", description: "", routeScope: "{\n \"routes\": []\n}" }); const [joinTokenForm, setJoinTokenForm] = useState({ ttlHours: 24, maxUses: 1, roles: ["entry-node"], ownershipType: "platform_managed", purpose: "", }); const [lastJoinToken, setLastJoinToken] = useState(null); const [authorityForm, setAuthorityForm] = useState({ authorityState: "authoritative", mutationMode: "normal", notes: "" }); const [roleOrgScope, setRoleOrgScope] = useState(""); const [nodeViewScope, setNodeViewScope] = useState<"cluster" | "all">("cluster"); const [allNodeSearch, setAllNodeSearch] = useState(""); const [nodeGroupFilterId, setNodeGroupFilterId] = useState(""); const [collapsedNodeGroupKeys, setCollapsedNodeGroupKeys] = useState([]); const [allNodeGroupBy, setAllNodeGroupBy] = useState<"membership" | "health" | "ownership" | "cluster_count">("membership"); const [attachNodeDialog, setAttachNodeDialog] = useState<{ node: ClusterNode; memberships: Array<{ cluster: Cluster; node: ClusterNode }> } | null>(null); const [attachNodeRoles, setAttachNodeRoles] = useState([]); const [nodeInfoDialog, setNodeInfoDialog] = useState(null); const [nodeInfoMode, setNodeInfoMode] = useState<"details" | "manage">("details"); const [nodeRoleDrafts, setNodeRoleDrafts] = useState>({}); const [nodeWorkloadDrafts, setNodeWorkloadDrafts] = useState>({}); const [entryPointNodeDrafts, setEntryPointNodeDrafts] = useState>({}); const [egressPoolNodeDrafts, setEgressPoolNodeDrafts] = useState>({}); const [nodeTestingDrafts, setNodeTestingDrafts] = useState>({}); const [testingOrgId, setTestingOrgId] = useState(""); const [testingOrgDraft, setTestingOrgDraft] = useState({ telemetry: true, links: true }); const [workloadForm, setWorkloadForm] = useState({ nodeId: "", serviceType: "entry-node", desiredState: "enabled", runtimeMode: "container", version: "", configJson: "{}", environmentJson: "{}", }); const [vpnForm, setVPNForm] = useState({ organizationId: "", name: "", protocolFamily: "generic", desiredState: "disabled", credentialRef: "", targetEndpointJson: "{}", allowedNodePolicyJson: `{"mode":"explicit","node_ids":[]}`, routingUsageJson: "[]", routePolicyJson: "{}", qosPolicyJson: "{}", placementPolicyJson: "{}", }); const client = useMemo(() => new AdminApiClient({ baseUrl, actorUserId }), [baseUrl, actorUserId]); const authClient = useMemo(() => new AdminApiClient({ baseUrl, actorUserId: "" }), [baseUrl]); const clusterScopeRequestSeq = useRef(0); const t = copy[language]; const selectedCluster = clusters.find((cluster) => cluster.id === selectedClusterId) || null; const selectedSummary = clusterSummaries.find((summary) => summary.cluster_id === selectedClusterId) || null; const joinTokenScope = useMemo(() => buildJoinTokenScope(joinTokenForm), [joinTokenForm]); const allNodeInventory = useMemo(() => { const grouped = new Map< string, { node: ClusterNode; memberships: Array<{ cluster: Cluster; node: ClusterNode }>; } >(); for (const cluster of clusters) { for (const node of allNodesByCluster[cluster.id] || []) { const current = grouped.get(node.id); if (current) { current.memberships.push({ cluster, node }); if ((node.last_seen_at || "") > (current.node.last_seen_at || "")) { current.node = node; } } else { grouped.set(node.id, { node, memberships: [{ cluster, node }] }); } } } return Array.from(grouped.values()).sort((left, right) => left.node.name.localeCompare(right.node.name)); }, [allNodesByCluster, clusters]); const groupedAllNodeInventory = useMemo( () => groupNodeInventory(allNodeInventory, selectedClusterId, allNodeSearch, allNodeGroupBy, language), [allNodeInventory, allNodeGroupBy, allNodeSearch, language, selectedClusterId], ); const visibleNodeInventory = useMemo(() => { const normalizedSearch = allNodeSearch.trim().toLowerCase(); const allowedGroupIds = nodeGroupFilterId ? new Set([nodeGroupFilterId, ...descendantGroupIds(nodeGroupFilterId, nodeGroups)]) : null; return allNodeInventory.filter((entry) => { const hasActiveClusterMembership = entry.memberships.some((membership) => membership.cluster.id === selectedClusterId); if (nodeViewScope !== "all" && !hasActiveClusterMembership) { return false; } if (allowedGroupIds) { const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId); if (!activeMembership?.node.node_group_id || !allowedGroupIds.has(activeMembership.node.node_group_id)) { return false; } } return !normalizedSearch || nodeInventoryMatches(entry, normalizedSearch); }); }, [allNodeInventory, allNodeSearch, nodeGroupFilterId, nodeGroups, nodeViewScope, selectedClusterId]); const visibleNodeTreeRows = useMemo( () => buildNodeInventoryTreeRows(visibleNodeInventory, nodeGroups, selectedClusterId, t, new Set(collapsedNodeGroupKeys)), [collapsedNodeGroupKeys, nodeGroups, selectedClusterId, t, visibleNodeInventory], ); useEffect(() => { let cancelled = false; authClient .getInstallationStatus() .then((status) => { if (!cancelled) { setInstallationStatus(status); } }) .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : "Не удалось загрузить статус установки."); } }); return () => { cancelled = true; }; }, [authClient]); useEffect(() => { if (!selectedCluster) { setClusterSettingsForm({ name: "", status: "active", region: "", metadataJson: "{}" }); return; } setClusterSettingsForm({ name: selectedCluster.name, status: selectedCluster.status || "active", region: selectedCluster.region || "", metadataJson: JSON.stringify(selectedCluster.metadata || {}, null, 2), }); }, [selectedCluster]); useEffect(() => { setNodeGroupFilterId(""); setNodeGroupForm({ name: "", parentGroupId: "" }); setCollapsedNodeGroupKeys([]); }, [selectedClusterId]); useEffect(() => { setAttachNodeDialog(null); setAttachNodeRoles([]); }, [selectedClusterId]); useEffect(() => { localStorage.setItem(storageKeys.baseUrl, baseUrl); localStorage.setItem(storageKeys.language, language); if (session) { localStorage.setItem(`${storageKeys.language}.${session.userId}`, language); } else { localStorage.removeItem(storageKeys.auth); localStorage.removeItem(storageKeys.actorUserId); } }, [baseUrl, actorUserId, language, session]); useEffect(() => { if (!session) { return; } const userLanguage = localStorage.getItem(`${storageKeys.language}.${session.userId}`); if (userLanguage === "ru" || userLanguage === "en") { setLanguage(userLanguage); } }, [session?.userId]); useEffect(() => { if (session) { void refreshAll(); } // First load only. The operator keeps refresh timing explicit after that. // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.userId]); async function refreshAll(preferredClusterId = selectedClusterId) { if (!actorUserId.trim()) { setError(t.noLoginError); return; } setLoading(true); setError(""); setNotice(""); try { const [loadedClusters, loadedSummaries] = await Promise.all([client.listClusters(), client.listClusterSummaries()]); setClusters(loadedClusters); setClusterSummaries(loadedSummaries); const allNodeEntries = await Promise.all(loadedClusters.map(async (cluster) => [cluster.id, await client.listNodes(cluster.id)] as const)); setAllNodesByCluster(Object.fromEntries(allNodeEntries)); const clusterId = preferredClusterId || loadedClusters[0]?.id || ""; setSelectedClusterId(clusterId); if (clusterId) { await loadClusterScope(clusterId); } } catch (err) { setError(err instanceof Error ? err.message : "Неизвестная ошибка панели управления платформой."); } finally { setLoading(false); } } async function loadClusterScope(clusterId: string) { const requestSeq = ++clusterScopeRequestSeq.current; const [ loadedNodes, loadedNodeGroups, loadedJoinRequests, loadedAuthority, loadedAudit, loadedMeshLinks, loadedQosPolicies, loadedEntryPoints, loadedEgressPools, loadedVPNConnections, loadedTestingFlags, ] = await Promise.all([ client.listNodes(clusterId), client.listNodeGroups(clusterId), client.listJoinRequests(clusterId), client.getClusterAuthority(clusterId), client.listAudit(clusterId), client.listMeshLinks(clusterId), client.listQoSPolicies(clusterId), client.listFabricEntryPoints(clusterId), client.listFabricEgressPools(clusterId), client.listVPNConnections(clusterId), client.listFabricTestingFlags(), ]); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setNodes(loadedNodes); setNodeGroups(loadedNodeGroups); setJoinRequests(loadedJoinRequests); setAuthority(loadedAuthority); setAuthorityForm({ authorityState: loadedAuthority.authority_state, mutationMode: loadedAuthority.mutation_mode, notes: loadedAuthority.notes || "", }); setAudit(loadedAudit); setMeshLinks(loadedMeshLinks); setQosPolicies(loadedQosPolicies); setEntryPoints(loadedEntryPoints); setEgressPools(loadedEgressPools); setVPNConnections(loadedVPNConnections); setTestingFlags(loadedTestingFlags); const [entryPointNodeEntries, egressPoolNodeEntries] = await Promise.all([ Promise.all(loadedEntryPoints.map(async (entryPoint) => [entryPoint.id, await client.listFabricEntryPointNodes(clusterId, entryPoint.id)] as const)), Promise.all(loadedEgressPools.map(async (pool) => [pool.id, await client.listFabricEgressPoolNodes(clusterId, pool.id)] as const)), ]); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setEntryPointNodesById(Object.fromEntries(entryPointNodeEntries)); setEgressPoolNodesById(Object.fromEntries(egressPoolNodeEntries)); const roleEntries = await Promise.all(loadedNodes.map(async (node) => [node.id, await client.listNodeRoles(clusterId, node.id)] as const)); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setRolesByNode(Object.fromEntries(roleEntries)); const desiredEntries = await Promise.all(loadedNodes.map(async (node) => [node.id, await client.listDesiredWorkloads(clusterId, node.id)] as const)); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setDesiredWorkloadsByNode(Object.fromEntries(desiredEntries)); const workloadEntries = await Promise.all( loadedNodes.map(async (node) => [node.id, await client.listWorkloadStatuses(clusterId, node.id)] as const), ); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setWorkloadsByNode(Object.fromEntries(workloadEntries)); const heartbeatEntries = await Promise.all( loadedNodes.map(async (node) => [node.id, await client.listNodeHeartbeats(clusterId, node.id, 60)] as const), ); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setHeartbeatsByNode(Object.fromEntries(heartbeatEntries)); const telemetryEntries = await Promise.all( loadedNodes.map(async (node) => [node.id, await client.listNodeTelemetry(clusterId, node.id, 120)] as const), ); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setTelemetryByNode(Object.fromEntries(telemetryEntries)); const syntheticMeshConfigEntries = await Promise.all( loadedNodes.map(async (node) => [node.id, await client.getNodeSyntheticMeshConfig(clusterId, node.id)] as const), ); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setSyntheticMeshConfigsByNode(Object.fromEntries(syntheticMeshConfigEntries)); const leaseEntries = await Promise.all( loadedVPNConnections.map(async (connection) => [connection.id, await client.getActiveVPNLease(clusterId, connection.id)] as const), ); if (requestSeq !== clusterScopeRequestSeq.current) { return; } setVPNLeases(Object.fromEntries(leaseEntries)); } function clearClusterScope() { setNodes([]); setNodeGroups([]); setJoinRequests([]); setAuthority(null); setRolesByNode({}); setDesiredWorkloadsByNode({}); setWorkloadsByNode({}); setHeartbeatsByNode({}); setTelemetryByNode({}); setMeshLinks([]); setSyntheticMeshConfigsByNode({}); setQosPolicies([]); setEntryPoints([]); setEntryPointNodesById({}); setEgressPools([]); setEgressPoolNodesById({}); setTestingFlags([]); setVPNConnections([]); setVPNLeases({}); setAudit([]); } async function runAction(action: () => Promise, success: string) { setLoading(true); setError(""); setNotice(""); try { await action(); setNotice(success); await refreshAll(); } catch (err) { setError(err instanceof Error ? err.message : "Действие не выполнено."); } finally { setLoading(false); } } async function handleLogin() { setLoading(true); setError(""); setNotice(""); try { const result = await authClient.login({ email: loginForm.email, password: loginForm.password, deviceLabel: loginForm.deviceLabel, trustDevice: loginForm.trustDevice, }); const nextSession = normalizeAuthResult(result); if (!nextSession.userId || !nextSession.authSessionId) { throw new Error("Ответ входа не содержит пользователя или сессию."); } const accessClient = new AdminApiClient({ baseUrl, actorUserId: nextSession.userId }); try { await accessClient.listClusterSummaries(); } catch { try { await authClient.revokeAuthSession({ userId: nextSession.userId, authSessionId: nextSession.authSessionId, reason: "platform_owner_console_access_denied", }); } catch { // Authentication succeeded, but this panel is not authorized for the user. } throw new Error(t.accessDenied); } setSession(nextSession); setActorUserId(nextSession.userId); setNotice(`${t.signedInAs}: ${nextSession.email}`); } catch (err) { setError(err instanceof Error ? err.message : "Вход не выполнен."); } finally { setLoading(false); } } async function handleBootstrapOwner() { setLoading(true); setError(""); setNotice(""); try { let activationPayload: unknown; if (installationStatus?.strict_authority) { if (!bootstrapForm.activationPayload.trim() || !bootstrapForm.activationSignature.trim()) { throw new Error(t.bootstrapText); } activationPayload = JSON.parse(bootstrapForm.activationPayload); } const result = await authClient.bootstrapOwner({ email: bootstrapForm.email, password: bootstrapForm.password, activationPayload, activationSignature: bootstrapForm.activationSignature, }); setInstallationStatus(result.installation); setLoginForm({ ...loginForm, email: bootstrapForm.email, password: bootstrapForm.password }); setNotice(t.ownerCreated); } catch (err) { setError(err instanceof Error ? err.message : "Создание владельца не выполнено."); } finally { setLoading(false); } } async function handleLogout() { const previous = session; setSession(null); setActorUserId(""); setClusters([]); setClusterSummaries([]); clearClusterScope(); setAllNodesByCluster({}); setSelectedClusterId(""); if (previous?.userId && previous.authSessionId) { try { await authClient.revokeAuthSession({ userId: previous.userId, authSessionId: previous.authSessionId, reason: "platform_owner_console_logout", }); } catch { // The local session is already cleared. Backend revoke failure should not trap the operator in the UI. } } } async function switchCluster(clusterId: string) { setSelectedClusterId(clusterId); clearClusterScope(); setLoading(true); setError(""); setNotice(""); try { await loadClusterScope(clusterId); } catch (err) { setError(err instanceof Error ? err.message : "Не удалось загрузить кластер."); } finally { setLoading(false); } } const pendingJoinCount = joinRequests.filter((request) => request.status === "pending").length; const healthyNodeCount = nodes.filter((node) => node.health_status === "healthy").length; const riskyNodeCount = nodes.filter((node) => node.health_status === "critical" || node.membership_status !== "active").length; const activeRoleCount = Object.values(rolesByNode).flat().filter((role) => role.status === "active").length; const platformTestingFlag = testingFlags.find((flag) => flag.scope_type === "platform" && !flag.scope_id) || null; const organizationTestingFlag = testingFlags.find((flag) => flag.scope_type === "organization" && flag.scope_id === testingOrgId && (!flag.cluster_id || flag.cluster_id === selectedClusterId)) || null; const syntheticMeshConfigs = Object.values(syntheticMeshConfigsByNode); const syntheticConfigEnabledCount = syntheticMeshConfigs.filter((config) => config.enabled).length; const syntheticRouteCount = syntheticMeshConfigs.reduce((sum, config) => sum + config.routes.length, 0); const syntheticPeerEndpointCount = syntheticMeshConfigs.reduce((sum, config) => sum + Object.keys(config.peer_endpoints || {}).length, 0); const syntheticCandidateCount = syntheticMeshConfigs.reduce((sum, config) => sum + countPeerEndpointCandidates(config), 0); const syntheticPeerDirectoryCount = syntheticMeshConfigs.reduce((sum, config) => sum + (config.peer_directory?.length ?? 0), 0); const syntheticRecoverySeedCount = syntheticMeshConfigs.reduce((sum, config) => sum + (config.recovery_seeds?.length ?? 0), 0); const productionForwardingConfigCount = syntheticMeshConfigs.filter((config) => config.production_forwarding).length; const bootstrapRequired = installationStatus?.bootstrapped === false; const bootstrapBlocked = bootstrapRequired && !installationStatus?.strict_authority && !installationStatus?.insecure_bootstrap_allowed; if (!session) { return (
{installationStatus && (

{installationStatus.bootstrapped ? t.installationLocked : t.bootstrapTitle}

{installationStatus.root_fingerprint && }
)} {bootstrapRequired ? (

{t.bootstrapTitle}

{bootstrapBlocked ? t.insecureBootstrapDisabled : t.bootstrapText}

{installationStatus?.strict_authority && ( <>