4536 lines
214 KiB
TypeScript
4536 lines
214 KiB
TypeScript
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<string, string[]> = {
|
||
"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<Language, Record<string, string>>;
|
||
|
||
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<WebAdminSession | null>(null);
|
||
const [language, setLanguage] = useState<Language>(() => (localStorage.getItem(storageKeys.language) === "en" ? "en" : "ru"));
|
||
const [actorUserId, setActorUserId] = useState("");
|
||
const [loginForm, setLoginForm] = useState({
|
||
email: "",
|
||
password: "",
|
||
deviceLabel: "Панель владельца платформы",
|
||
trustDevice: true,
|
||
});
|
||
const [installationStatus, setInstallationStatus] = useState<InstallationStatus | null>(null);
|
||
const [bootstrapForm, setBootstrapForm] = useState({
|
||
email: "",
|
||
password: "",
|
||
activationPayload: "",
|
||
activationSignature: "",
|
||
});
|
||
const [activeView, setActiveView] = useState<ViewId>("command");
|
||
const [selectedClusterId, setSelectedClusterId] = useState("");
|
||
|
||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||
const [clusterSummaries, setClusterSummaries] = useState<ClusterAdminSummary[]>([]);
|
||
const [authority, setAuthority] = useState<ClusterAuthorityState | null>(null);
|
||
const [nodes, setNodes] = useState<ClusterNode[]>([]);
|
||
const [nodeGroups, setNodeGroups] = useState<ClusterNodeGroup[]>([]);
|
||
const [allNodesByCluster, setAllNodesByCluster] = useState<Record<string, ClusterNode[]>>({});
|
||
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
|
||
const [rolesByNode, setRolesByNode] = useState<Record<string, RoleAssignment[]>>({});
|
||
const [desiredWorkloadsByNode, setDesiredWorkloadsByNode] = useState<Record<string, NodeWorkloadDesiredState[]>>({});
|
||
const [workloadsByNode, setWorkloadsByNode] = useState<Record<string, WorkloadStatus[]>>({});
|
||
const [heartbeatsByNode, setHeartbeatsByNode] = useState<Record<string, NodeHeartbeat[]>>({});
|
||
const [telemetryByNode, setTelemetryByNode] = useState<Record<string, NodeTelemetryObservation[]>>({});
|
||
const [meshLinks, setMeshLinks] = useState<MeshLink[]>([]);
|
||
const [syntheticMeshConfigsByNode, setSyntheticMeshConfigsByNode] = useState<Record<string, NodeSyntheticMeshConfig>>({});
|
||
const [qosPolicies, setQosPolicies] = useState<QoSPolicy[]>([]);
|
||
const [entryPoints, setEntryPoints] = useState<FabricEntryPoint[]>([]);
|
||
const [entryPointNodesById, setEntryPointNodesById] = useState<Record<string, FabricEntryPointNode[]>>({});
|
||
const [egressPools, setEgressPools] = useState<FabricEgressPool[]>([]);
|
||
const [egressPoolNodesById, setEgressPoolNodesById] = useState<Record<string, FabricEgressPoolNode[]>>({});
|
||
const [testingFlags, setTestingFlags] = useState<FabricTestingFlag[]>([]);
|
||
const [vpnConnections, setVPNConnections] = useState<VPNConnection[]>([]);
|
||
const [vpnLeases, setVPNLeases] = useState<Record<string, VPNConnectionLease | null>>({});
|
||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||
|
||
const [organizationId, setOrganizationId] = useState("");
|
||
const [organizationSummary, setOrganizationSummary] = useState<OrganizationAdminSummary | null>(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<CreatedJoinToken | null>(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<string[]>([]);
|
||
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<string[]>([]);
|
||
const [nodeInfoDialog, setNodeInfoDialog] = useState<NodeInventoryEntry | null>(null);
|
||
const [nodeInfoMode, setNodeInfoMode] = useState<"details" | "manage">("details");
|
||
const [nodeRoleDrafts, setNodeRoleDrafts] = useState<Record<string, string>>({});
|
||
const [nodeWorkloadDrafts, setNodeWorkloadDrafts] = useState<Record<string, string>>({});
|
||
const [entryPointNodeDrafts, setEntryPointNodeDrafts] = useState<Record<string, string>>({});
|
||
const [egressPoolNodeDrafts, setEgressPoolNodeDrafts] = useState<Record<string, string>>({});
|
||
const [nodeTestingDrafts, setNodeTestingDrafts] = useState<Record<string, { telemetry: boolean; links: boolean }>>({});
|
||
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<unknown>, 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 (
|
||
<main className="loginShell">
|
||
{installationStatus && (
|
||
<section className="loginCard">
|
||
<h1>{installationStatus.bootstrapped ? t.installationLocked : t.bootstrapTitle}</h1>
|
||
<StateLine label="Authority" value={`${installationStatus.authority_mode}/${installationStatus.authority_state}`} />
|
||
<StateLine label="Strict" value={installationStatus.strict_authority ? "enabled" : "legacy"} />
|
||
{installationStatus.root_fingerprint && <StateLine label="Root key" value={shortId(installationStatus.root_fingerprint)} />}
|
||
</section>
|
||
)}
|
||
|
||
{bootstrapRequired ? (
|
||
<section className="loginCard">
|
||
<h1>{t.bootstrapTitle}</h1>
|
||
<p className="loginHint">{bootstrapBlocked ? t.insecureBootstrapDisabled : t.bootstrapText}</p>
|
||
<label>
|
||
{t.email}
|
||
<input
|
||
value={bootstrapForm.email}
|
||
onChange={(event) => setBootstrapForm({ ...bootstrapForm, email: event.target.value })}
|
||
autoComplete="username"
|
||
/>
|
||
</label>
|
||
<label>
|
||
{t.password}
|
||
<input
|
||
value={bootstrapForm.password}
|
||
onChange={(event) => setBootstrapForm({ ...bootstrapForm, password: event.target.value })}
|
||
type="password"
|
||
autoComplete="new-password"
|
||
/>
|
||
</label>
|
||
{installationStatus?.strict_authority && (
|
||
<>
|
||
<label>
|
||
{t.activationPayload}
|
||
<textarea
|
||
value={bootstrapForm.activationPayload}
|
||
onChange={(event) => setBootstrapForm({ ...bootstrapForm, activationPayload: event.target.value })}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<label>
|
||
{t.activationSignature}
|
||
<input
|
||
value={bootstrapForm.activationSignature}
|
||
onChange={(event) => setBootstrapForm({ ...bootstrapForm, activationSignature: event.target.value })}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
|
||
{error && <div className="errorPanel">{error}</div>}
|
||
{notice && <div className="noticePanel">{notice}</div>}
|
||
|
||
<button
|
||
className="primary wide"
|
||
onClick={() => void handleBootstrapOwner()}
|
||
disabled={
|
||
loading ||
|
||
bootstrapBlocked ||
|
||
!bootstrapForm.email ||
|
||
bootstrapForm.password.length < 12 ||
|
||
(installationStatus?.strict_authority && (!bootstrapForm.activationPayload || !bootstrapForm.activationSignature))
|
||
}
|
||
>
|
||
{loading ? t.creatingOwner : t.createOwner}
|
||
</button>
|
||
</section>
|
||
) : (
|
||
<section className="loginCard">
|
||
<h1>{t.signInTitle}</h1>
|
||
<label>
|
||
{t.email}
|
||
<input
|
||
value={loginForm.email}
|
||
onChange={(event) => setLoginForm({ ...loginForm, email: event.target.value })}
|
||
autoComplete="username"
|
||
/>
|
||
</label>
|
||
<label>
|
||
{t.password}
|
||
<input
|
||
value={loginForm.password}
|
||
onChange={(event) => setLoginForm({ ...loginForm, password: event.target.value })}
|
||
type="password"
|
||
autoComplete="current-password"
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
void handleLogin();
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
{error && <div className="errorPanel">{error}</div>}
|
||
{notice && <div className="noticePanel">{notice}</div>}
|
||
|
||
<button className="primary wide" onClick={() => void handleLogin()} disabled={loading || !loginForm.email || !loginForm.password}>
|
||
{loading ? t.signingIn : t.signIn}
|
||
</button>
|
||
</section>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="consoleShell">
|
||
<aside className="sideRail">
|
||
<div className="brandMark">SAF</div>
|
||
<p className="sideKicker">{t.productOwner}</p>
|
||
<h1>{t.controlPlane}</h1>
|
||
<p className="sideText">{t.sideText}</p>
|
||
<nav className="railNav">
|
||
{views.filter((view) => view.id !== "enrollment" && view.id !== "roles").map((view) => (
|
||
<button key={view.id} className={activeView === view.id ? "active" : ""} onClick={() => setActiveView(view.id)}>
|
||
{view[language]}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</aside>
|
||
|
||
<section className="workspace">
|
||
<header className="topBar">
|
||
<div>
|
||
<p className="eyebrow">Secure Access Fabric</p>
|
||
<h2>{selectedCluster ? selectedCluster.name : t.consoleTitle}</h2>
|
||
<p className="muted">{t.boundary}</p>
|
||
</div>
|
||
<div className="clusterPicker">
|
||
<label>
|
||
{t.activeCluster}
|
||
<select value={selectedClusterId} onChange={(event) => void switchCluster(event.target.value)}>
|
||
{clusters.map((cluster) => (
|
||
<option key={cluster.id} value={cluster.id}>
|
||
{cluster.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<span>
|
||
{t.slugLabel}: {selectedCluster?.slug || "н/д"}
|
||
</span>
|
||
</div>
|
||
<button className="primary" onClick={() => void refreshAll()} disabled={loading}>
|
||
{loading ? t.refreshing : t.refresh}
|
||
</button>
|
||
<div className="profilePanel">
|
||
<strong>{t.profile}</strong>
|
||
<span>{session.email}</span>
|
||
<label>
|
||
{t.language}
|
||
<select value={language} onChange={(event) => setLanguage(event.target.value as Language)}>
|
||
<option value="ru">Русский</option>
|
||
<option value="en">{language === "ru" ? "Английский" : "English"}</option>
|
||
</select>
|
||
</label>
|
||
<button className="ghost" onClick={() => void handleLogout()} disabled={loading}>
|
||
{t.logout}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{error && <div className="errorPanel">{error}</div>}
|
||
{notice && <div className="noticePanel">{notice}</div>}
|
||
{selectedCluster && nodes.length === 0 && (
|
||
<div className="noticePanel">
|
||
<strong>{t.emptyLiveTitle}.</strong> {t.emptyLiveText}
|
||
</div>
|
||
)}
|
||
|
||
{activeView === "command" && (
|
||
<section className="grid five">
|
||
<Metric label="Кластеры" value={clusters.length} tone="steel" />
|
||
<Metric label="Узлы в области" value={nodes.length} tone="green" />
|
||
<Metric label="Здоровые узлы" value={healthyNodeCount} tone="green" />
|
||
<Metric label="Ожидают подключения" value={pendingJoinCount} tone="amber" />
|
||
<Metric label="Рискованные состояния" value={riskyNodeCount} tone="red" />
|
||
|
||
<article className="card span3">
|
||
<h3>Общее состояние кластеров</h3>
|
||
<DataTable
|
||
columns={["кластер", "authority", "ключ", "режим изменений", "узлы", "заявки", "роли", "последний сигнал"]}
|
||
rows={clusterSummaries.map((summary) => [
|
||
summary.name,
|
||
summary.authority_state,
|
||
shortId(summary.cluster_key_fingerprint),
|
||
summary.mutation_mode,
|
||
`${summary.healthy_node_count}/${summary.node_count}`,
|
||
String(summary.pending_join_count),
|
||
String(summary.active_role_assignment_count),
|
||
formatDate(summary.last_node_seen_at),
|
||
])}
|
||
/>
|
||
</article>
|
||
|
||
<article className="card span2">
|
||
<h3>Authority выбранного кластера</h3>
|
||
{authority ? (
|
||
<div className="stateList">
|
||
<StateLine label="Authority" value={authority.authority_state} />
|
||
<StateLine label="Режим изменений" value={authority.mutation_mode} />
|
||
<StateLine label="Терм" value={String(authority.term)} />
|
||
<StateLine label="Cluster key" value={shortId(selectedSummary?.cluster_key_fingerprint)} />
|
||
<StateLine label="Обновлено" value={formatDate(authority.updated_at)} />
|
||
</div>
|
||
) : (
|
||
<EmptyState title="Нет состояния authority" text="Выберите кластер, чтобы загрузить состояние authority." />
|
||
)}
|
||
</article>
|
||
|
||
<article className="card span2">
|
||
<h3>Граница платформы</h3>
|
||
<p className="muted">
|
||
Эта панель предназначена для владельца продукта / владельца платформы. Панели организаций должны использовать безопасные
|
||
проекции и не раскрывать mesh internals, peer cache, route cache, секреты или данные других tenants.
|
||
</p>
|
||
</article>
|
||
|
||
<article className="card span3">
|
||
<h3>Текущие сигналы кластера</h3>
|
||
<div className="signalStrip">
|
||
<Signal label="Активные роли" value={String(activeRoleCount)} />
|
||
<Signal label="Отчеты сервисов" value={String(Object.values(workloadsByNode).filter((items) => items.length > 0).length)} />
|
||
<Signal label="Наблюдения связей" value={String(meshLinks.length)} />
|
||
<Signal label="Synthetic configs" value={`${syntheticConfigEnabledCount}/${nodes.length}`} />
|
||
</div>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "clusters" && (
|
||
<section className="grid two">
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.clusterCatalog}</h3>
|
||
<p className="muted">{t.clusterCatalogText}</p>
|
||
</div>
|
||
<span className="pill">{clusterCountLabel(clusters.length, language)}</span>
|
||
</div>
|
||
<div className="clusterCatalog">
|
||
{clusters.map((cluster) => {
|
||
const summary = clusterSummaries.find((item) => item.cluster_id === cluster.id);
|
||
const isSelected = cluster.id === selectedClusterId;
|
||
return (
|
||
<article key={cluster.id} className={`clusterCard ${isSelected ? "selected" : ""}`}>
|
||
<div className="clusterCardMain">
|
||
<div>
|
||
<p className="eyebrow">{cluster.region || "регион не задан"}</p>
|
||
<h4>{cluster.name}</h4>
|
||
<p className="muted">
|
||
{t.slugLabel}: <strong>{cluster.slug}</strong>
|
||
</p>
|
||
</div>
|
||
<div className="clusterCardActions">
|
||
<Status value={cluster.status} />
|
||
{isSelected ? (
|
||
<span className="pill good">{t.selected}</span>
|
||
) : (
|
||
<button onClick={() => void switchCluster(cluster.id)}>{t.makeActive}</button>
|
||
)}
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
void switchCluster(cluster.id);
|
||
setActiveView("cluster-settings");
|
||
}}
|
||
>
|
||
{t.openSettings}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="signalStrip compact">
|
||
<Signal label="Узлы" value={summary ? `${summary.healthy_node_count}/${summary.node_count}` : "н/д"} />
|
||
<Signal label="Заявки" value={String(summary?.pending_join_count ?? "н/д")} />
|
||
<Signal label="Роли" value={String(summary?.active_role_assignment_count ?? "н/д")} />
|
||
<Signal label="Последний сигнал" value={formatDate(summary?.last_node_seen_at)} />
|
||
</div>
|
||
|
||
<details>
|
||
<summary>{t.clusterDetails}</summary>
|
||
<div className="stateList">
|
||
<StateLine label="ID" value={cluster.id} />
|
||
<StateLine label={t.slugLabel} value={cluster.slug} />
|
||
<StateLine label="Статус" value={statusLabel(cluster.status)} />
|
||
<StateLine label="Authority" value={summary ? `${summary.authority_state}/${summary.mutation_mode}` : "неизвестно"} />
|
||
<StateLine label="Создан" value={formatDate(cluster.created_at)} />
|
||
<StateLine label="Обновлен" value={formatDate(cluster.updated_at || cluster.created_at)} />
|
||
</div>
|
||
</details>
|
||
</article>
|
||
);
|
||
})}
|
||
{clusters.length === 0 && <EmptyState title="Кластеров нет" text="Создайте первый кластер, затем подключите стартовый node-agent." />}
|
||
</div>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>{t.createCluster}</h3>
|
||
<FormGrid>
|
||
<label>
|
||
{t.slugLabel}
|
||
<input value={clusterForm.slug} onChange={(event) => setClusterForm({ ...clusterForm, slug: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Название
|
||
<input value={clusterForm.name} onChange={(event) => setClusterForm({ ...clusterForm, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Регион
|
||
<input value={clusterForm.region} onChange={(event) => setClusterForm({ ...clusterForm, region: event.target.value })} />
|
||
</label>
|
||
</FormGrid>
|
||
<p className="muted">{t.slugHelp}</p>
|
||
<button
|
||
className="primary"
|
||
disabled={!clusterForm.slug || !clusterForm.name}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
await client.createCluster({ slug: clusterForm.slug, name: clusterForm.name, region: clusterForm.region || null });
|
||
setClusterForm({ slug: "", name: "", region: "" });
|
||
}, "Кластер создан.")
|
||
}
|
||
>
|
||
{t.createCluster}
|
||
</button>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Что такое технический код?</h3>
|
||
<p className="muted">{t.slugHelp}</p>
|
||
<p className="muted">
|
||
Для человека основное поле — название. Для системы и операторов — технический код. Он нужен, чтобы сценарии, логи и будущие
|
||
endpoint-адреса не зависели от переименования кластера.
|
||
</p>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "cluster-settings" && (
|
||
<section className="grid two">
|
||
{!selectedCluster && <EmptyState title="Кластер не выбран" text="Выберите активный кластер, чтобы открыть настройки." />}
|
||
|
||
{selectedCluster && (
|
||
<>
|
||
<article className="card">
|
||
<h3>Identity кластера</h3>
|
||
<p className="muted">Базовые параметры хранятся в PostgreSQL. Slug остается неизменяемым идентификатором для операторов и скриптов.</p>
|
||
<FormGrid>
|
||
<label>
|
||
ID
|
||
<input value={selectedCluster.id} readOnly />
|
||
</label>
|
||
<label>
|
||
Slug
|
||
<input value={selectedCluster.slug} readOnly />
|
||
</label>
|
||
<label>
|
||
Название
|
||
<input
|
||
value={clusterSettingsForm.name}
|
||
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, name: event.target.value })}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Статус
|
||
<select
|
||
value={clusterSettingsForm.status}
|
||
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, status: event.target.value })}
|
||
>
|
||
<option value="active">active, работает</option>
|
||
<option value="disabled">disabled, отключен</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Регион
|
||
<input
|
||
value={clusterSettingsForm.region}
|
||
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, region: event.target.value })}
|
||
placeholder="например ru-msk-1"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Обновлен
|
||
<input value={formatDate(selectedCluster.updated_at || selectedCluster.created_at)} readOnly />
|
||
</label>
|
||
</FormGrid>
|
||
<label className="wideLabel">
|
||
Metadata JSON
|
||
<textarea
|
||
value={clusterSettingsForm.metadataJson}
|
||
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, metadataJson: event.target.value })}
|
||
rows={8}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<button
|
||
className="primary"
|
||
disabled={!clusterSettingsForm.name.trim()}
|
||
onClick={() =>
|
||
confirmHighRisk("Сохранить базовые настройки кластера") &&
|
||
void runAction(async () => {
|
||
const metadata = parseJSONObject(clusterSettingsForm.metadataJson || "{}", "Metadata JSON");
|
||
await client.updateCluster(selectedCluster.id, {
|
||
name: clusterSettingsForm.name,
|
||
status: clusterSettingsForm.status,
|
||
region: clusterSettingsForm.region || null,
|
||
metadata,
|
||
});
|
||
}, "Настройки кластера сохранены.")
|
||
}
|
||
>
|
||
Сохранить настройки кластера
|
||
</button>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Authority и режим изменений</h3>
|
||
<p className="muted">Эта секция защищает кластер от split-brain: minority/read-only сегменты не должны принимать изменения политик.</p>
|
||
<div className="stateGrid">
|
||
<StateLine label="Authority" value={authority?.authority_state || "неизвестно"} />
|
||
<StateLine label="Mutation mode" value={authority?.mutation_mode || "неизвестно"} />
|
||
<StateLine label="Term" value={String(authority?.term ?? "н/д")} />
|
||
<StateLine label="Cluster key" value={shortId(selectedSummary?.cluster_key_fingerprint)} />
|
||
<StateLine label="Последнее изменение" value={formatDate(authority?.updated_at)} />
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
Состояние authority
|
||
<select
|
||
value={authorityForm.authorityState}
|
||
onChange={(event) => setAuthorityForm({ ...authorityForm, authorityState: event.target.value })}
|
||
>
|
||
<option value="authoritative">authoritative, основной</option>
|
||
<option value="minority">minority, меньшинство</option>
|
||
<option value="isolated">isolated, изолирован</option>
|
||
<option value="recovery">recovery, восстановление</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Режим изменений
|
||
<select value={authorityForm.mutationMode} onChange={(event) => setAuthorityForm({ ...authorityForm, mutationMode: event.target.value })}>
|
||
<option value="normal">normal, обычный</option>
|
||
<option value="read_only">read_only, только чтение</option>
|
||
<option value="recovery_override">recovery_override, восстановление</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Примечание
|
||
<input value={authorityForm.notes} onChange={(event) => setAuthorityForm({ ...authorityForm, notes: event.target.value })} />
|
||
</label>
|
||
</FormGrid>
|
||
<button
|
||
disabled={!selectedClusterId}
|
||
onClick={() =>
|
||
confirmHighRisk("Изменить authority state кластера") &&
|
||
void runAction(
|
||
() =>
|
||
client.updateClusterAuthority(selectedClusterId, {
|
||
authorityState: authorityForm.authorityState,
|
||
mutationMode: authorityForm.mutationMode,
|
||
notes: authorityForm.notes,
|
||
}),
|
||
"Authority кластера обновлен.",
|
||
)
|
||
}
|
||
>
|
||
Обновить authority
|
||
</button>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Safety / quorum</h3>
|
||
<div className="stateGrid">
|
||
<StateLine label="Узлы" value={String(selectedSummary?.node_count ?? nodes.length)} />
|
||
<StateLine label="Healthy" value={String(selectedSummary?.healthy_node_count ?? healthyNodeCount)} />
|
||
<StateLine label="Pending join" value={String(selectedSummary?.pending_join_count ?? joinRequests.filter((item) => item.status === "pending").length)} />
|
||
<StateLine label="Последний узел" value={formatDate(selectedSummary?.last_node_seen_at)} />
|
||
</div>
|
||
<p className="muted">
|
||
Минимальный размер, quorum policy и split-brain rules пока не имеют отдельного runtime-переключателя. Сейчас защита выполняется через
|
||
authority/mutation mode, explicit node approval и аудит.
|
||
</p>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Telemetry / testing</h3>
|
||
<div className="stateGrid">
|
||
<StateLine label="Telemetry flag" value={platformTestingFlag?.telemetry_enabled ? "включен" : "выключен"} />
|
||
<StateLine label="Synthetic links" value={platformTestingFlag?.synthetic_links_enabled ? "включены" : "выключены"} />
|
||
<StateLine label="Хранение истории, часов" value={String(platformTestingFlag?.history_retention_hours ?? "н/д")} />
|
||
</div>
|
||
<p className="muted">
|
||
Это тестовый контур наблюдаемости: heartbeat/telemetry реальные, а связи Fabric сейчас synthetic. Production mesh traffic здесь пока не
|
||
отображается.
|
||
</p>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Storage / updates</h3>
|
||
<div className="stateGrid">
|
||
<StateLine label="Version Storage" value="архитектура зафиксирована, runtime не реализован" />
|
||
<StateLine label="Update cache" value={`${nodesWithRole("update-cache", rolesByNode).length} узл.`} />
|
||
<StateLine label="File/config cache" value={`${nodesWithRole("file-storage-cache", rolesByNode).length} узл.`} />
|
||
</div>
|
||
<p className="muted">
|
||
Version Storage будет хранить stable/current/candidate и signed artifacts. Сейчас это не production updater runtime.
|
||
</p>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Admin endpoints</h3>
|
||
<div className="stateGrid">
|
||
<StateLine label="Entry nodes" value={`${nodesWithRole("entry-node", rolesByNode).length} узл.`} />
|
||
<StateLine label="Relay nodes" value={`${nodesWithRole("relay-node", rolesByNode).length} узл.`} />
|
||
<StateLine label="Core mesh" value={`${nodesWithRole("core-mesh", rolesByNode).length} узл.`} />
|
||
</div>
|
||
<p className="muted">
|
||
Панель кластера не переезжает автоматически на storage-узел. Cluster Admin Endpoint должен быть назначен отдельной explicit ролью на
|
||
ingress/admin-capable узле.
|
||
</p>
|
||
</article>
|
||
</>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "nodes" && (
|
||
<section className="grid two">
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.nodeManagement}</h3>
|
||
<p className="muted">
|
||
Единый краткий список узлов. По умолчанию показан активный кластер; включите общий режим, чтобы увидеть весь инвентарь платформы.
|
||
</p>
|
||
</div>
|
||
<div className="actions">
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={nodeViewScope === "all"}
|
||
onChange={(event) => setNodeViewScope(event.target.checked ? "all" : "cluster")}
|
||
/>
|
||
{t.showAllPlatformNodes}
|
||
</label>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setNodeViewScope("all");
|
||
setAllNodeSearch("");
|
||
}}
|
||
>
|
||
{t.showAllPlatformNodes}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="signalStrip compact">
|
||
<Signal label="Узлы активного кластера" value={String(nodes.length)} />
|
||
<Signal label="Все узлы" value={String(allNodeInventory.length)} />
|
||
<Signal label="Заявки" value={String(pendingJoinCount)} />
|
||
<Signal label="Активные роли" value={String(activeRoleCount)} />
|
||
</div>
|
||
<p className="muted">{t.addNodeText}</p>
|
||
</article>
|
||
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.nodeBriefList}</h3>
|
||
<p className="muted">{t.nodeBriefListHelp}</p>
|
||
</div>
|
||
<span className="pill">{visibleNodeInventory.length}</span>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
{t.nodeSearch}
|
||
<input value={allNodeSearch} onChange={(event) => setAllNodeSearch(event.target.value)} placeholder={t.nodeSearchPlaceholder} />
|
||
</label>
|
||
<label>
|
||
{t.nodeGroupFilter}
|
||
<select value={nodeGroupFilterId} onChange={(event) => setNodeGroupFilterId(event.target.value)}>
|
||
<option value="">{t.allNodeGroups}</option>
|
||
{nodeGroups.map((group) => (
|
||
<option key={group.id} value={group.id}>
|
||
{groupPathLabel(group, nodeGroups)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</FormGrid>
|
||
<p className="muted">{t.nodeGroupInventoryText}</p>
|
||
<h4>{t.nodeGroupCreatePanel}</h4>
|
||
<FormGrid>
|
||
<label>
|
||
{t.nodeGroupName}
|
||
<input value={nodeGroupForm.name} onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
{t.parentNodeGroup}
|
||
<select value={nodeGroupForm.parentGroupId} onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, parentGroupId: event.target.value })}>
|
||
<option value="">{t.rootNodeGroup}</option>
|
||
{nodeGroups.map((group) => (
|
||
<option key={group.id} value={group.id}>
|
||
{groupPathLabel(group, nodeGroups)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
{t.createNodeGroup}
|
||
<button
|
||
className="primary"
|
||
disabled={!nodeGroupForm.name.trim()}
|
||
onClick={() =>
|
||
void runAction(
|
||
async () => {
|
||
await client.createNodeGroup(selectedClusterId, {
|
||
name: nodeGroupForm.name,
|
||
parentGroupId: nodeGroupForm.parentGroupId || null,
|
||
});
|
||
setNodeGroupForm({ name: "", parentGroupId: "" });
|
||
},
|
||
t.nodeGroupCreated,
|
||
)
|
||
}
|
||
>
|
||
{t.createNodeGroup}
|
||
</button>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="nodeList">
|
||
{visibleNodeTreeRows.map((row) => {
|
||
if (row.kind === "group") {
|
||
const isCollapsed = collapsedNodeGroupKeys.includes(row.key);
|
||
return (
|
||
<div className="nodeListGroup" key={row.key} style={{ paddingLeft: `${row.depth * 18}px` }}>
|
||
<div className="nodeListMain">
|
||
<strong>{row.label}</strong>
|
||
{row.groupId && <span>{groupPathLabelById(row.groupId, nodeGroups)}</span>}
|
||
</div>
|
||
<div className="actions">
|
||
<span className="pill">{row.count}</span>
|
||
<button className="ghost" onClick={() => setCollapsedNodeGroupKeys(toggleArrayValue(collapsedNodeGroupKeys, row.key))}>
|
||
{isCollapsed ? t.expandGroup : t.collapseGroup}
|
||
</button>
|
||
{row.groupId && (
|
||
<button className="ghost" onClick={() => setNodeGroupForm({ name: "", parentGroupId: row.groupId || "" })}>
|
||
{t.createSubgroup}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
const entry = row.entry;
|
||
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
|
||
const displayNode = activeMembership?.node || entry.node;
|
||
const isActiveMember = activeMembership?.node.membership_status === "active";
|
||
const isRevokedMember = activeMembership?.node.membership_status === "revoked";
|
||
return (
|
||
<div className="nodeListRow" key={row.key} style={{ marginLeft: `${row.depth * 18}px` }}>
|
||
<div className="nodeListMain">
|
||
<strong>{displayNode.name}</strong>
|
||
<span>{displayNode.node_key}</span>
|
||
</div>
|
||
<Status value={displayNode.health_status} />
|
||
<span className="muted">{displayNode.reported_version || "версия неизвестна"}</span>
|
||
<span className="muted">{formatDate(displayNode.last_seen_at)}</span>
|
||
{activeMembership ? <Status value={activeMembership.node.membership_status} /> : <span className="muted">{t.notMemberOfActiveCluster}</span>}
|
||
<div className="actions">
|
||
<button
|
||
onClick={() => {
|
||
setNodeInfoDialog(entry);
|
||
setNodeInfoMode("details");
|
||
}}
|
||
>
|
||
{t.nodeDetails}
|
||
</button>
|
||
{isActiveMember ? (
|
||
<button
|
||
className="primary"
|
||
onClick={() => {
|
||
setNodeInfoDialog(entry);
|
||
setNodeInfoMode("manage");
|
||
}}
|
||
>
|
||
{t.manageNode}
|
||
</button>
|
||
) : isRevokedMember ? (
|
||
<span className="muted">{t.revokedMembership}</span>
|
||
) : (
|
||
<button
|
||
className="primary"
|
||
onClick={() => {
|
||
setAttachNodeDialog(entry);
|
||
setAttachNodeRoles([]);
|
||
}}
|
||
>
|
||
{t.connectExistingNode}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{visibleNodeTreeRows.length === 0 && <EmptyState title={t.noNodesTitle} text={t.noNodesByFilter} />}
|
||
</div>
|
||
</article>
|
||
|
||
{attachNodeDialog && (
|
||
<div className="modalBackdrop" role="presentation">
|
||
<div className="modalCard" role="dialog" aria-modal="true" aria-labelledby="attach-node-title">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3 id="attach-node-title">{t.connectExistingNodeTitle}</h3>
|
||
<p className="muted">{t.connectExistingNodeText}</p>
|
||
</div>
|
||
<button className="ghost" onClick={() => setAttachNodeDialog(null)}>
|
||
{t.cancel}
|
||
</button>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="Узел" value={attachNodeDialog.node.name} />
|
||
<StateLine label="Node key" value={attachNodeDialog.node.node_key} />
|
||
<StateLine label={t.activeCluster} value={selectedCluster?.name || selectedClusterId} />
|
||
</div>
|
||
<div className="checkGrid">
|
||
{roleOptions.map((role) => (
|
||
<label className="checkLine" key={role}>
|
||
<input type="checkbox" checked={attachNodeRoles.includes(role)} onChange={() => setAttachNodeRoles(toggleArrayValue(attachNodeRoles, role))} />
|
||
{role}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
onClick={() =>
|
||
void runAction(
|
||
async () => {
|
||
await client.attachExistingNode(selectedClusterId, attachNodeDialog.node.id, attachNodeRoles);
|
||
setAttachNodeDialog(null);
|
||
setAttachNodeRoles([]);
|
||
setNodeViewScope("cluster");
|
||
},
|
||
"Узел подключен к активному кластеру.",
|
||
)
|
||
}
|
||
>
|
||
{t.connectWithRoles}
|
||
</button>
|
||
<button onClick={() => setAttachNodeDialog(null)}>{t.cancel}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{nodeInfoDialog &&
|
||
(() => {
|
||
const activeMembership = nodeInfoDialog.memberships.find((membership) => membership.cluster.id === selectedClusterId);
|
||
const node = activeMembership?.node || nodeInfoDialog.node;
|
||
const latestHeartbeat = activeMembership ? (heartbeatsByNode[node.id] || [])[0] : undefined;
|
||
const activeRoles = activeMembership ? (rolesByNode[node.id] || []).filter((role) => role.status === "active") : [];
|
||
const desiredWorkloads = activeMembership ? desiredWorkloadsByNode[node.id] || [] : [];
|
||
const observedWorkloads = activeMembership ? workloadsByNode[node.id] || [] : [];
|
||
return (
|
||
<div className="modalBackdrop" role="presentation">
|
||
<div className="modalCard wide" role="dialog" aria-modal="true" aria-labelledby="node-info-title">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3 id="node-info-title">
|
||
{nodeInfoMode === "manage" ? t.manageNode : t.nodeDetails}: {node.name}
|
||
</h3>
|
||
<p className="muted">{node.node_key}</p>
|
||
</div>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setNodeInfoDialog(null);
|
||
setNodeInfoMode("details");
|
||
}}
|
||
>
|
||
{t.close}
|
||
</button>
|
||
</div>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeIdentity}</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Node ID" value={shortId(node.id)} />
|
||
<StateLine label="Ключ узла" value={node.node_key} />
|
||
<StateLine label="Тип владения" value={statusLabel(node.ownership_type)} />
|
||
<StateLine label="Owner org" value={shortId(node.owner_organization_id)} />
|
||
<StateLine label="Регистрация" value={statusLabel(node.registration_status)} />
|
||
<StateLine label="Здоровье" value={statusLabel(node.health_status)} />
|
||
<StateLine label="Версия" value={node.reported_version || "неизвестно"} />
|
||
<StateLine label="Последний сигнал" value={formatDate(node.last_seen_at)} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{t.clusterMemberships}</h4>
|
||
<div className="membershipList">
|
||
{nodeInfoDialog.memberships.map((membership) => (
|
||
<span key={membership.cluster.id} className={membership.cluster.id === selectedClusterId ? "pill good" : "pill"}>
|
||
{membership.cluster.name}: {statusLabel(membership.node.membership_status)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{activeMembership ? (
|
||
<>
|
||
<section className="nodePanel">
|
||
<h4>{t.activeClusterScope}</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Участие" value={statusLabel(node.membership_status)} />
|
||
<StateLine label="Сегмент" value={statusLabel(node.partition_state)} />
|
||
<StateLine label="Группа" value={node.node_group_name || t.ungroupedNodes} />
|
||
<StateLine label="Ролей" value={String(activeRoles.length)} />
|
||
<StateLine label="Desired-сервисов" value={String(desiredWorkloads.length)} />
|
||
<StateLine label="Observed-сервисов" value={String(observedWorkloads.length)} />
|
||
</div>
|
||
</section>
|
||
|
||
{nodeInfoMode === "details" && (
|
||
<>
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeRoles}</h4>
|
||
<div className="serviceTags">
|
||
{activeRoles.length === 0 && <p className="muted">{t.noRoles}</p>}
|
||
{activeRoles.map((role) => (
|
||
<div className="serviceTag" key={role.id}>
|
||
<strong>{role.role}</strong>
|
||
<span>{role.organization_id ? `organization: ${shortId(role.organization_id)}` : "cluster-wide"}</span>
|
||
<small>{formatDate(role.assigned_at)}</small>
|
||
<span className={`pill ${capabilityPillTone(role.role, latestHeartbeat)}`}>{capabilityLabel(role.role, latestHeartbeat, t)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{t.desiredServices}</h4>
|
||
<div className="serviceTags">
|
||
{desiredWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
|
||
{desiredWorkloads.map((workload) => (
|
||
<div className="serviceTag" key={`${workload.service_type}-${workload.runtime_mode}`}>
|
||
<strong>{workload.service_type}</strong>
|
||
<span>
|
||
{statusLabel(workload.desired_state)} / {workload.runtime_mode}
|
||
</span>
|
||
<small>{workload.version || "версия не закреплена"}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{t.observedServices}</h4>
|
||
<div className="serviceTags">
|
||
{observedWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
|
||
{observedWorkloads.map((workload) => (
|
||
<div className="serviceTag" key={workload.id}>
|
||
<strong>{workload.service_type}</strong>
|
||
<span>
|
||
{statusLabel(workload.reported_state)} / {workload.runtime_mode}
|
||
</span>
|
||
<small>{formatDate(workload.observed_at)}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeTelemetry}</h4>
|
||
<TelemetryStrip items={telemetryByNode[node.id] || []} emptyText={t.noTelemetry} />
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{t.recentHeartbeats}</h4>
|
||
<DataTable
|
||
columns={["состояние", "версия", "mesh recovery", "mesh intents", "rv leases", "path decisions", "route gen", "route health", "наблюдение"]}
|
||
rows={(heartbeatsByNode[node.id] || []).slice(0, 5).map((heartbeat) => [
|
||
heartbeat.health_status,
|
||
heartbeat.reported_version || "неизвестно",
|
||
meshRecoverySummary(heartbeat),
|
||
meshConnectionIntentSummary(heartbeat),
|
||
meshRendezvousLeaseSummary(heartbeat),
|
||
meshRoutePathDecisionSummary(heartbeat),
|
||
meshRouteGenerationSummary(heartbeat),
|
||
meshRouteHealthConfigSummary(heartbeat),
|
||
formatDate(heartbeat.observed_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{nodeInfoMode === "manage" && (
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeFunctions}</h4>
|
||
<p className="muted">{t.nodeFunctionsText}</p>
|
||
<label className="wideLabel">
|
||
{t.organizationScopeForEnable}
|
||
<input value={roleOrgScope} onChange={(event) => setRoleOrgScope(event.target.value)} placeholder={t.clusterWideRolePlaceholder} />
|
||
</label>
|
||
<div className="functionList">
|
||
{roleOptions.map((role) => {
|
||
const activeRole = activeRoles.find((assignment) => assignment.role === role);
|
||
const desired = desiredWorkloads.find((workload) => workload.service_type === role);
|
||
const observed = observedWorkloads.find((workload) => workload.service_type === role);
|
||
const capabilityTone = capabilityPillTone(role, latestHeartbeat);
|
||
const desiredState = desired?.desired_state || "disabled";
|
||
const observedState = observed?.reported_state || "missing";
|
||
const enabled = Boolean(activeRole) && desiredState === "enabled";
|
||
return (
|
||
<div className="functionRow" key={role}>
|
||
<div className="nodeListMain">
|
||
<strong>{role}</strong>
|
||
<span>{capabilityOptionLabel(role, latestHeartbeat, language)}</span>
|
||
</div>
|
||
<span className={`pill ${activeRole ? "good" : ""}`}>{activeRole ? t.permissionGranted : t.permissionDenied}</span>
|
||
<Status value={desiredState} />
|
||
<Status value={observedState} />
|
||
<span className={`pill ${capabilityTone}`}>{capabilityLabel(role, latestHeartbeat, t)}</span>
|
||
<div className="actions">
|
||
<button
|
||
className={enabled ? "" : "primary"}
|
||
disabled={enabled}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
if (!activeRole) {
|
||
await client.setRoleStatus(selectedClusterId, node.id, role, "active", roleOrgScope || undefined);
|
||
}
|
||
await client.setDesiredWorkload(selectedClusterId, node.id, role, {
|
||
desiredState: "enabled",
|
||
runtimeMode: "container",
|
||
config: {},
|
||
environment: {},
|
||
});
|
||
}, `${role}: функция включена.`)
|
||
}
|
||
>
|
||
{t.enableFunction}
|
||
</button>
|
||
<button
|
||
disabled={!activeRole && desiredState !== "enabled"}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
await client.setDesiredWorkload(selectedClusterId, node.id, role, {
|
||
desiredState: "disabled",
|
||
runtimeMode: desired?.runtime_mode || "container",
|
||
config: desired?.config || {},
|
||
environment: desired?.environment || {},
|
||
});
|
||
if (activeRole) {
|
||
await client.setRoleStatus(selectedClusterId, node.id, role, "disabled", activeRole.organization_id || undefined);
|
||
}
|
||
}, `${role}: функция выключена.`)
|
||
}
|
||
>
|
||
{t.disableFunction}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="actions">
|
||
<select
|
||
value={node.node_group_id || ""}
|
||
onChange={(event) =>
|
||
void runAction(
|
||
() => client.assignNodeGroup(selectedClusterId, node.id, event.target.value || null),
|
||
event.target.value ? "Узел перемещен в группу." : "Узел убран из группы.",
|
||
)
|
||
}
|
||
>
|
||
<option value="">{t.ungroupedNodes}</option>
|
||
{nodeGroups.map((group) => (
|
||
<option key={group.id} value={group.id}>
|
||
{groupPathLabel(group, nodeGroups)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
onClick={() =>
|
||
confirmHighRisk(`Отключить участие узла ${node.name}`) &&
|
||
void runAction(
|
||
() => client.disableMembership(selectedClusterId, node.id, "Отключено из панели владельца платформы."),
|
||
"Участие узла отключено.",
|
||
)
|
||
}
|
||
>
|
||
Отключить участие
|
||
</button>
|
||
<button
|
||
className="danger"
|
||
onClick={() =>
|
||
confirmHighRisk(`Отозвать identity узла ${node.name}`) &&
|
||
void runAction(
|
||
() => client.revokeNodeIdentity(selectedClusterId, node.id, "Отозвано из панели владельца платформы."),
|
||
"Identity узла отозван.",
|
||
)
|
||
}
|
||
>
|
||
Отозвать identity
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
) : (
|
||
<section className="nodePanel">
|
||
<h4>{t.noActiveClusterMembership}</h4>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
onClick={() => {
|
||
setAttachNodeDialog(nodeInfoDialog);
|
||
setAttachNodeRoles([]);
|
||
setNodeInfoDialog(null);
|
||
}}
|
||
>
|
||
{t.connectExistingNode}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{false && (nodeViewScope === "all" ? (
|
||
<article className="card span2">
|
||
<h3>{t.allNodes}</h3>
|
||
<p className="muted">{t.nodeGlobalInventoryText}</p>
|
||
<FormGrid>
|
||
<label>
|
||
{t.nodeSearch}
|
||
<input value={allNodeSearch} onChange={(event) => setAllNodeSearch(event.target.value)} placeholder="имя, ключ, кластер, статус" />
|
||
</label>
|
||
<label>
|
||
{t.groupNodesBy}
|
||
<select value={allNodeGroupBy} onChange={(event) => setAllNodeGroupBy(event.target.value as typeof allNodeGroupBy)}>
|
||
<option value="membership">{t.groupByMembership}</option>
|
||
<option value="health">{t.groupByHealth}</option>
|
||
<option value="ownership">{t.groupByOwnership}</option>
|
||
<option value="cluster_count">{t.groupByClusterCount}</option>
|
||
</select>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="stack">
|
||
{groupedAllNodeInventory.map((group) => (
|
||
<section className="nodePanel" key={group.label}>
|
||
<div className="cardHead">
|
||
<h4>{group.label}</h4>
|
||
<span className="pill">{group.items.length}</span>
|
||
</div>
|
||
<DataTable
|
||
columns={["узел", "ключ", t.currentClusterMembership, t.clusterMemberships, "здоровье", "версия", "последний сигнал", "действие"]}
|
||
rows={group.items.map((entry) => {
|
||
const { node, memberships } = entry;
|
||
const activeMembership = memberships.find((membership) => membership.cluster.id === selectedClusterId);
|
||
const isActiveMember = activeMembership?.node.membership_status === "active";
|
||
const isRevokedMember = activeMembership?.node.membership_status === "revoked";
|
||
return [
|
||
node.name,
|
||
node.node_key,
|
||
activeMembership ? <Status value={activeMembership.node.membership_status} /> : <span className="muted">{t.notMemberOfActiveCluster}</span>,
|
||
<div className="membershipList">
|
||
{memberships.map((membership) => (
|
||
<span key={`${membership.cluster.id}-${membership.node.id}`} className={membership.cluster.id === selectedClusterId ? "pill good" : "pill"}>
|
||
{membership.cluster.name}: {statusLabel(membership.node.membership_status)}
|
||
</span>
|
||
))}
|
||
</div>,
|
||
statusLabel(node.health_status),
|
||
node.reported_version || "неизвестно",
|
||
formatDate(node.last_seen_at),
|
||
<div className="actions" key={`${selectedClusterId}-${node.id}`}>
|
||
{isActiveMember ? (
|
||
<button
|
||
onClick={() => {
|
||
setNodeViewScope("cluster");
|
||
}}
|
||
>
|
||
{t.manageInCluster}
|
||
</button>
|
||
) : isRevokedMember ? (
|
||
<span className="muted">{t.revokedMembership}</span>
|
||
) : (
|
||
<button
|
||
className="primary"
|
||
onClick={() => {
|
||
setAttachNodeDialog(entry);
|
||
setAttachNodeRoles([]);
|
||
}}
|
||
>
|
||
{t.connectExistingNode}
|
||
</button>
|
||
)}
|
||
</div>,
|
||
];
|
||
})}
|
||
/>
|
||
</section>
|
||
))}
|
||
{groupedAllNodeInventory.length === 0 && <EmptyState title="Нет узлов" text="По текущему фильтру узлы не найдены." />}
|
||
</div>
|
||
{attachNodeDialog && (
|
||
<div className="modalBackdrop" role="presentation">
|
||
<div className="modalCard" role="dialog" aria-modal="true" aria-labelledby="attach-node-title">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3 id="attach-node-title">{t.connectExistingNodeTitle}</h3>
|
||
<p className="muted">{t.connectExistingNodeText}</p>
|
||
</div>
|
||
<button className="ghost" onClick={() => setAttachNodeDialog(null)}>
|
||
{t.cancel}
|
||
</button>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="Узел" value={attachNodeDialog!.node.name} />
|
||
<StateLine label="Node key" value={attachNodeDialog!.node.node_key} />
|
||
<StateLine label={t.activeCluster} value={selectedCluster?.name || selectedClusterId} />
|
||
</div>
|
||
<div className="checkGrid">
|
||
{roleOptions.map((role) => (
|
||
<label className="checkLine" key={role}>
|
||
<input
|
||
type="checkbox"
|
||
checked={attachNodeRoles.includes(role)}
|
||
onChange={() => setAttachNodeRoles(toggleArrayValue(attachNodeRoles, role))}
|
||
/>
|
||
{role}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
onClick={() =>
|
||
void runAction(
|
||
async () => {
|
||
await client.attachExistingNode(selectedClusterId, attachNodeDialog!.node.id, attachNodeRoles);
|
||
setAttachNodeDialog(null);
|
||
setAttachNodeRoles([]);
|
||
setNodeViewScope("cluster");
|
||
},
|
||
"Узел подключен к активному кластеру.",
|
||
)
|
||
}
|
||
>
|
||
{t.connectWithRoles}
|
||
</button>
|
||
<button onClick={() => setAttachNodeDialog(null)}>{t.cancel}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
) : (
|
||
<>
|
||
<article className="card span2">
|
||
<h3>{t.testMode}</h3>
|
||
<p className="muted">{t.testModeText}</p>
|
||
<div className="actions">
|
||
<button
|
||
className={platformTestingFlag?.telemetry_enabled ? "primary" : ""}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.updateFabricTestingFlag({
|
||
scopeType: "platform",
|
||
enabled: true,
|
||
telemetryEnabled: !platformTestingFlag?.telemetry_enabled,
|
||
syntheticLinksEnabled: Boolean(platformTestingFlag?.synthetic_links_enabled),
|
||
historyRetentionHours: platformTestingFlag?.history_retention_hours || 24,
|
||
}),
|
||
platformTestingFlag?.telemetry_enabled ? "Тестовая телеметрия платформы выключена." : "Тестовая телеметрия платформы включена.",
|
||
)
|
||
}
|
||
>
|
||
{t.enableTelemetry}: {platformTestingFlag?.telemetry_enabled ? "ВКЛ" : "ВЫКЛ"}
|
||
</button>
|
||
<button
|
||
className={platformTestingFlag?.synthetic_links_enabled ? "primary" : ""}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.updateFabricTestingFlag({
|
||
scopeType: "platform",
|
||
enabled: true,
|
||
telemetryEnabled: Boolean(platformTestingFlag?.telemetry_enabled),
|
||
syntheticLinksEnabled: !platformTestingFlag?.synthetic_links_enabled,
|
||
historyRetentionHours: platformTestingFlag?.history_retention_hours || 24,
|
||
}),
|
||
platformTestingFlag?.synthetic_links_enabled ? "Тестовые связи выключены." : "Тестовые связи включены.",
|
||
)
|
||
}
|
||
>
|
||
{t.enableSyntheticLinks}: {platformTestingFlag?.synthetic_links_enabled ? "ВКЛ" : "ВЫКЛ"}
|
||
</button>
|
||
</div>
|
||
<section className="nodePanel">
|
||
<h4>{t.organizationTestFlag}</h4>
|
||
<p className="muted">
|
||
Флаг уровня организации нужен для безопасной проверки телеметрии и истории в tenant scope. Он не раскрывает внутреннюю topology
|
||
organization-панелям.
|
||
</p>
|
||
<FormGrid>
|
||
<label>
|
||
{t.organizationId}
|
||
<input value={testingOrgId} onChange={(event) => setTestingOrgId(event.target.value)} placeholder="UUID организации" />
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={testingOrgDraft.telemetry}
|
||
onChange={(event) => setTestingOrgDraft({ ...testingOrgDraft, telemetry: event.target.checked })}
|
||
/>
|
||
{t.enableTelemetry}
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={testingOrgDraft.links}
|
||
onChange={(event) => setTestingOrgDraft({ ...testingOrgDraft, links: event.target.checked })}
|
||
/>
|
||
{t.enableSyntheticLinks}
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
disabled={!testingOrgId}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.updateFabricTestingFlag({
|
||
scopeType: "organization",
|
||
scopeId: testingOrgId,
|
||
clusterId: selectedClusterId,
|
||
enabled: true,
|
||
telemetryEnabled: testingOrgDraft.telemetry,
|
||
syntheticLinksEnabled: testingOrgDraft.links,
|
||
historyRetentionHours: organizationTestingFlag?.history_retention_hours || 24,
|
||
}),
|
||
"Флаг тестирования организации сохранен.",
|
||
)
|
||
}
|
||
>
|
||
{t.saveOrganizationFlag}
|
||
</button>
|
||
{organizationTestingFlag && <Status value={organizationTestingFlag?.enabled ? "enabled" : "disabled"} />}
|
||
</div>
|
||
</section>
|
||
</article>
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.nodeGroups}</h3>
|
||
<p className="muted">
|
||
Группы принадлежат активному кластеру и могут быть вложенными. Один узел может быть в другой группе в другом кластере.
|
||
</p>
|
||
</div>
|
||
<span className="pill">{nodeGroups.length}</span>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
{t.nodeGroupName}
|
||
<input value={nodeGroupForm.name} onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
{t.parentNodeGroup}
|
||
<select
|
||
value={nodeGroupForm.parentGroupId}
|
||
onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, parentGroupId: event.target.value })}
|
||
>
|
||
<option value="">{t.rootNodeGroup}</option>
|
||
{nodeGroups.map((group) => (
|
||
<option key={group.id} value={group.id}>
|
||
{groupPathLabel(group, nodeGroups)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
disabled={!nodeGroupForm.name.trim()}
|
||
onClick={() =>
|
||
void runAction(
|
||
async () => {
|
||
await client.createNodeGroup(selectedClusterId, {
|
||
name: nodeGroupForm.name,
|
||
parentGroupId: nodeGroupForm.parentGroupId || null,
|
||
});
|
||
setNodeGroupForm({ name: "", parentGroupId: "" });
|
||
},
|
||
"Группа узлов создана.",
|
||
)
|
||
}
|
||
>
|
||
{t.createNodeGroup}
|
||
</button>
|
||
</div>
|
||
<NodeGroupTree groups={nodeGroups} nodes={nodes} labels={t} />
|
||
</article>
|
||
{nodes.map((node) => {
|
||
const latestHeartbeat = (heartbeatsByNode[node.id] || [])[0];
|
||
const activeRoles = (rolesByNode[node.id] || []).filter((role) => role.status === "active");
|
||
const desiredWorkloads = desiredWorkloadsByNode[node.id] || [];
|
||
const observedWorkloads = workloadsByNode[node.id] || [];
|
||
return (
|
||
<article className="card nodeCard" key={node.id}>
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{node.name}</h3>
|
||
<p className="muted">{node.node_key}</p>
|
||
</div>
|
||
<Status value={node.health_status} />
|
||
</div>
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeIdentity}</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Node ID" value={shortId(node.id)} />
|
||
<StateLine label="Ключ узла" value={node.node_key} />
|
||
<StateLine label="Тип владения" value={statusLabel(node.ownership_type)} />
|
||
<StateLine label="Owner org" value={shortId(node.owner_organization_id)} />
|
||
<StateLine label="Регистрация" value={statusLabel(node.registration_status)} />
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.activeClusterScope}</h4>
|
||
<p className="muted">{t.activeClusterScopeText}</p>
|
||
<div className="stateList">
|
||
<StateLine label="Участие" value={statusLabel(node.membership_status)} />
|
||
<StateLine label="Сегмент" value={statusLabel(node.partition_state)} />
|
||
<StateLine label="Здоровье" value={statusLabel(node.health_status)} />
|
||
<StateLine label="Группа" value={node.node_group_name || t.ungroupedNodes} />
|
||
<StateLine label="Версия" value={node.reported_version || "неизвестно"} />
|
||
<StateLine label="Последний сигнал" value={formatDate(node.last_seen_at)} />
|
||
<StateLine label="Ролей в кластере" value={String(activeRoles.length)} />
|
||
<StateLine label="Desired-сервисов" value={String(desiredWorkloads.length)} />
|
||
<StateLine label="Observed-сервисов" value={String(observedWorkloads.length)} />
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeRoles}</h4>
|
||
<div className="serviceTags">
|
||
{activeRoles.length === 0 && <p className="muted">{t.noRoles}</p>}
|
||
{activeRoles.map((role) => (
|
||
<div className="serviceTag" key={role.id}>
|
||
<strong>{role.role}</strong>
|
||
<span>{role.organization_id ? `organization: ${shortId(role.organization_id)}` : "cluster-wide"}</span>
|
||
<small>{formatDate(role.assigned_at)}</small>
|
||
<span className={`pill ${capabilityPillTone(role.role, latestHeartbeat)}`}>
|
||
{capabilityLabel(role.role, latestHeartbeat, t)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.desiredServices}</h4>
|
||
<div className="serviceTags">
|
||
{desiredWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
|
||
{desiredWorkloads.map((workload) => (
|
||
<div className="serviceTag" key={`${workload.service_type}-${workload.runtime_mode}`}>
|
||
<strong>{workload.service_type}</strong>
|
||
<span>{statusLabel(workload.desired_state)} / {workload.runtime_mode}</span>
|
||
<small>{workload.version || "версия не закреплена"}</small>
|
||
<span className={`pill ${capabilityPillTone(workload.service_type, latestHeartbeat)}`}>
|
||
{capabilityLabel(workload.service_type, latestHeartbeat, t)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.observedServices}</h4>
|
||
<div className="serviceTags">
|
||
{observedWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
|
||
{observedWorkloads.map((workload) => (
|
||
<div className="serviceTag" key={workload.id}>
|
||
<strong>{workload.service_type}</strong>
|
||
<span>{statusLabel(workload.reported_state)} / {workload.runtime_mode}</span>
|
||
<small>{formatDate(workload.observed_at)}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeManagement}</h4>
|
||
<div className="actions">
|
||
<select
|
||
value={nodeRoleDrafts[node.id] || ""}
|
||
onChange={(event) => setNodeRoleDrafts({ ...nodeRoleDrafts, [node.id]: event.target.value })}
|
||
>
|
||
<option value="">Роль...</option>
|
||
{roleOptions.map((role) => (
|
||
<option key={role} value={role}>
|
||
{role} — {capabilityOptionLabel(role, latestHeartbeat, language)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
disabled={!nodeRoleDrafts[node.id]}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.assignRole(selectedClusterId, node.id, nodeRoleDrafts[node.id], roleOrgScope || undefined),
|
||
"Роль назначена.",
|
||
)
|
||
}
|
||
>
|
||
Назначить роль
|
||
</button>
|
||
<select
|
||
value={nodeWorkloadDrafts[node.id] || ""}
|
||
onChange={(event) => setNodeWorkloadDrafts({ ...nodeWorkloadDrafts, [node.id]: event.target.value })}
|
||
>
|
||
<option value="">Сервис...</option>
|
||
{roleOptions.map((role) => (
|
||
<option key={role} value={role}>
|
||
{role} — {capabilityOptionLabel(role, latestHeartbeat, language)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
disabled={!nodeWorkloadDrafts[node.id]}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.setDesiredWorkload(selectedClusterId, node.id, nodeWorkloadDrafts[node.id], {
|
||
desiredState: "enabled",
|
||
runtimeMode: "container",
|
||
config: {},
|
||
environment: {},
|
||
}),
|
||
"Желаемое состояние сервиса включено.",
|
||
)
|
||
}
|
||
>
|
||
Включить сервис
|
||
</button>
|
||
<button
|
||
disabled={!nodeWorkloadDrafts[node.id]}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.setDesiredWorkload(selectedClusterId, node.id, nodeWorkloadDrafts[node.id], {
|
||
desiredState: "disabled",
|
||
runtimeMode: "container",
|
||
config: {},
|
||
environment: {},
|
||
}),
|
||
"Желаемое состояние сервиса выключено.",
|
||
)
|
||
}
|
||
>
|
||
Выключить сервис
|
||
</button>
|
||
<select
|
||
value={node.node_group_id || ""}
|
||
onChange={(event) =>
|
||
void runAction(
|
||
() => client.assignNodeGroup(selectedClusterId, node.id, event.target.value || null),
|
||
event.target.value ? "Узел перемещен в группу." : "Узел убран из группы.",
|
||
)
|
||
}
|
||
>
|
||
<option value="">{t.ungroupedNodes}</option>
|
||
{nodeGroups.map((group) => (
|
||
<option key={group.id} value={group.id}>
|
||
{groupPathLabel(group, nodeGroups)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.testMode}</h4>
|
||
<div className="actions">
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={nodeTestingDrafts[node.id]?.telemetry ?? true}
|
||
onChange={(event) =>
|
||
setNodeTestingDrafts({
|
||
...nodeTestingDrafts,
|
||
[node.id]: { telemetry: event.target.checked, links: nodeTestingDrafts[node.id]?.links ?? true },
|
||
})
|
||
}
|
||
/>
|
||
{t.enableTelemetry}
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={nodeTestingDrafts[node.id]?.links ?? true}
|
||
onChange={(event) =>
|
||
setNodeTestingDrafts({
|
||
...nodeTestingDrafts,
|
||
[node.id]: { telemetry: nodeTestingDrafts[node.id]?.telemetry ?? true, links: event.target.checked },
|
||
})
|
||
}
|
||
/>
|
||
{t.enableSyntheticLinks}
|
||
</label>
|
||
<button
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.updateFabricTestingFlag({
|
||
scopeType: "node",
|
||
scopeId: node.id,
|
||
clusterId: selectedClusterId,
|
||
enabled: true,
|
||
telemetryEnabled: nodeTestingDrafts[node.id]?.telemetry ?? true,
|
||
syntheticLinksEnabled: nodeTestingDrafts[node.id]?.links ?? true,
|
||
historyRetentionHours: 24,
|
||
}),
|
||
"Флаг тестирования узла сохранен.",
|
||
)
|
||
}
|
||
>
|
||
{t.saveTestFlag}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.nodeTelemetry}</h4>
|
||
<TelemetryStrip items={telemetryByNode[node.id] || []} emptyText={t.noTelemetry} />
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.recentHeartbeats}</h4>
|
||
<DataTable
|
||
columns={["состояние", "версия", "mesh recovery", "mesh intents", "rv leases", "path decisions", "route gen", "route health", "наблюдение"]}
|
||
rows={(heartbeatsByNode[node.id] || []).slice(0, 5).map((heartbeat) => [
|
||
heartbeat.health_status,
|
||
heartbeat.reported_version || "неизвестно",
|
||
meshRecoverySummary(heartbeat),
|
||
meshConnectionIntentSummary(heartbeat),
|
||
meshRendezvousLeaseSummary(heartbeat),
|
||
meshRoutePathDecisionSummary(heartbeat),
|
||
meshRouteGenerationSummary(heartbeat),
|
||
meshRouteHealthConfigSummary(heartbeat),
|
||
formatDate(heartbeat.observed_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
<div className="actions">
|
||
<button
|
||
onClick={() =>
|
||
confirmHighRisk(`Отключить участие узла ${node.name}`) &&
|
||
void runAction(
|
||
() => client.disableMembership(selectedClusterId, node.id, "Отключено из панели владельца платформы."),
|
||
"Участие узла отключено.",
|
||
)
|
||
}
|
||
>
|
||
Отключить участие
|
||
</button>
|
||
<button
|
||
className="danger"
|
||
onClick={() =>
|
||
confirmHighRisk(`Отозвать identity узла ${node.name}`) &&
|
||
void runAction(
|
||
() => client.revokeNodeIdentity(selectedClusterId, node.id, "Отозвано из панели владельца платформы."),
|
||
"Identity узла отозван.",
|
||
)
|
||
}
|
||
>
|
||
Отозвать identity
|
||
</button>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
{nodes.length === 0 && <EmptyState title="Нет узлов" text="Одобренные node-agent появятся здесь." />}
|
||
</>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "enrollment" && (
|
||
<section className="grid two">
|
||
<article className="card">
|
||
<h3>{t.joinTokenTitle}</h3>
|
||
<p className="muted">{t.joinTokenText}</p>
|
||
<FormGrid>
|
||
<label>
|
||
{t.ttlHours}
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={720}
|
||
value={joinTokenForm.ttlHours}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, ttlHours: Number(event.target.value) })}
|
||
/>
|
||
<small>{t.ttlHelp}</small>
|
||
</label>
|
||
<label>
|
||
{t.maxUses}
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={joinTokenForm.maxUses}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, maxUses: Number(event.target.value) })}
|
||
/>
|
||
<small>{t.maxUsesHelp}</small>
|
||
</label>
|
||
<label>
|
||
{t.nodeOwnership}
|
||
<select
|
||
value={joinTokenForm.ownershipType}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, ownershipType: event.target.value })}
|
||
>
|
||
<option value="platform_managed">platform_managed, управляется платформой</option>
|
||
<option value="customer_managed">customer_managed, управляется клиентом</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
{t.tokenPurpose}
|
||
<input
|
||
value={joinTokenForm.purpose}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, purpose: event.target.value })}
|
||
placeholder="например: стартовый entry-node в ru-msk-1"
|
||
/>
|
||
</label>
|
||
</FormGrid>
|
||
<section className="nodePanel">
|
||
<h4>{t.suggestedRoles}</h4>
|
||
<p className="muted">
|
||
Роли можно назначить узлу после approval. Здесь мы задаем безопасную ожидаемую область token, чтобы оператор видел назначение пропуска.
|
||
</p>
|
||
<div className="checkGrid">
|
||
{roleOptions.map((role) => (
|
||
<label className="checkLine" key={role}>
|
||
<input
|
||
type="checkbox"
|
||
checked={joinTokenForm.roles.includes(role)}
|
||
onChange={() => setJoinTokenForm({ ...joinTokenForm, roles: toggleArrayValue(joinTokenForm.roles, role) })}
|
||
/>
|
||
{role}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<details>
|
||
<summary>{t.generatedScope}</summary>
|
||
<p className="muted">{t.generatedScopeHelp}</p>
|
||
<pre className="codePreview">{JSON.stringify(joinTokenScope, null, 2)}</pre>
|
||
</details>
|
||
<p className="muted">{t.manualApprovalRequired}.</p>
|
||
<button
|
||
className="primary"
|
||
disabled={!selectedClusterId}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const token = await client.createJoinToken(selectedClusterId, {
|
||
ttlHours: joinTokenForm.ttlHours,
|
||
maxUses: joinTokenForm.maxUses,
|
||
scope: joinTokenScope,
|
||
});
|
||
setLastJoinToken(token);
|
||
}, "Join token создан.")
|
||
}
|
||
>
|
||
Создать token
|
||
</button>
|
||
{lastJoinToken && (
|
||
<div className="secretOnce">
|
||
<strong>Исходный token, возвращается один раз</strong>
|
||
<code>{lastJoinToken.token}</code>
|
||
<span className="muted">Authority key: {shortId(lastJoinToken.authority_signature?.key_fingerprint)}</span>
|
||
</div>
|
||
)}
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Заявки на подключение</h3>
|
||
<div className="stack">
|
||
{joinRequests.map((request) => (
|
||
<div className="requestCard" key={request.id}>
|
||
<div>
|
||
<strong>{request.node_name}</strong>
|
||
<p>{request.node_fingerprint}</p>
|
||
<Status value={request.status} />
|
||
{request.approval_signature?.key_fingerprint && (
|
||
<small className="muted">approval key {shortId(request.approval_signature.key_fingerprint)}</small>
|
||
)}
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
disabled={request.status !== "pending"}
|
||
onClick={() => void runAction(() => client.approveJoinRequest(selectedClusterId, request.id), "Заявка одобрена.")}
|
||
>
|
||
Одобрить
|
||
</button>
|
||
<button
|
||
disabled={request.status !== "pending"}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.rejectJoinRequest(selectedClusterId, request.id, "Отклонено из панели владельца платформы."),
|
||
"Заявка отклонена.",
|
||
)
|
||
}
|
||
>
|
||
Отклонить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{joinRequests.length === 0 && <EmptyState title="Нет заявок" text="Новые подключения node-agent появятся здесь." />}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "roles" && (
|
||
<section className="stack">
|
||
<article className="card">
|
||
<h3>Область ролей</h3>
|
||
<p className="muted">Capabilities — технические факты. Роли — явные разрешения. Область организации может ограничивать сервисные роли.</p>
|
||
<label>
|
||
UUID организации для новых назначений ролей, опционально
|
||
<input value={roleOrgScope} onChange={(event) => setRoleOrgScope(event.target.value)} placeholder="пусто = роль на весь кластер" />
|
||
</label>
|
||
</article>
|
||
{nodes.map((node) => (
|
||
<article className="card roleRow" key={node.id}>
|
||
<div>
|
||
<h3>{node.name}</h3>
|
||
<p>{summarizeRoles(rolesByNode[node.id] || [])}</p>
|
||
</div>
|
||
<select
|
||
defaultValue=""
|
||
onChange={(event) => {
|
||
const role = event.target.value;
|
||
event.currentTarget.value = "";
|
||
if (role) {
|
||
void runAction(() => client.assignRole(selectedClusterId, node.id, role, roleOrgScope || undefined), `${role} назначена узлу ${node.name}.`);
|
||
}
|
||
}}
|
||
>
|
||
<option value="">Назначить роль...</option>
|
||
{roleOptions.map((role) => (
|
||
<option key={role} value={role}>
|
||
{role}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</article>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "workloads" && (
|
||
<section className="grid two">
|
||
<article className="card">
|
||
<h3>Желаемое состояние сервиса</h3>
|
||
<p className="muted">Здесь задается только желаемое состояние. Runtime-исполнение остается под контролем node-agent и политик.</p>
|
||
<FormGrid>
|
||
<label>
|
||
Узел
|
||
<select value={workloadForm.nodeId} onChange={(event) => setWorkloadForm({ ...workloadForm, nodeId: event.target.value })}>
|
||
<option value="">Выберите узел...</option>
|
||
{nodes.map((node) => (
|
||
<option key={node.id} value={node.id}>
|
||
{node.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Сервис
|
||
<select value={workloadForm.serviceType} onChange={(event) => setWorkloadForm({ ...workloadForm, serviceType: event.target.value })}>
|
||
{roleOptions.map((role) => (
|
||
<option key={role} value={role}>
|
||
{role}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Желаемое состояние
|
||
<select value={workloadForm.desiredState} onChange={(event) => setWorkloadForm({ ...workloadForm, desiredState: event.target.value })}>
|
||
<option value="enabled">включено</option>
|
||
<option value="disabled">выключено</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Режим runtime
|
||
<select value={workloadForm.runtimeMode} onChange={(event) => setWorkloadForm({ ...workloadForm, runtimeMode: event.target.value })}>
|
||
<option value="container">контейнер</option>
|
||
<option value="native">нативно</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Версия
|
||
<input value={workloadForm.version} onChange={(event) => setWorkloadForm({ ...workloadForm, version: event.target.value })} />
|
||
</label>
|
||
</FormGrid>
|
||
<label>
|
||
Config JSON
|
||
<textarea value={workloadForm.configJson} onChange={(event) => setWorkloadForm({ ...workloadForm, configJson: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Environment JSON
|
||
<textarea
|
||
value={workloadForm.environmentJson}
|
||
onChange={(event) => setWorkloadForm({ ...workloadForm, environmentJson: event.target.value })}
|
||
/>
|
||
</label>
|
||
<button
|
||
className="primary"
|
||
disabled={!workloadForm.nodeId || !selectedClusterId}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.setDesiredWorkload(selectedClusterId, workloadForm.nodeId, workloadForm.serviceType, {
|
||
desiredState: workloadForm.desiredState,
|
||
runtimeMode: workloadForm.runtimeMode,
|
||
version: workloadForm.version,
|
||
config: parseJSONObject(workloadForm.configJson, "config сервиса"),
|
||
environment: parseJSONObject(workloadForm.environmentJson, "environment сервиса"),
|
||
}),
|
||
"Желаемое состояние сервиса обновлено.",
|
||
)
|
||
}
|
||
>
|
||
Задать желаемое состояние
|
||
</button>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Отчеты сервисов</h3>
|
||
<div className="stack">
|
||
{nodes.map((node) => (
|
||
<div className="workloadBlock" key={node.id}>
|
||
<strong>{node.name}</strong>
|
||
{(workloadsByNode[node.id] || []).length === 0 ? (
|
||
<p className="muted">Статус пока не получен.</p>
|
||
) : (
|
||
<DataTable
|
||
columns={["сервис", "состояние", "runtime", "наблюдение"]}
|
||
rows={(workloadsByNode[node.id] || []).map((workload) => [
|
||
workload.service_type,
|
||
workload.reported_state,
|
||
workload.runtime_mode,
|
||
formatDate(workload.observed_at),
|
||
])}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "fabric" && (
|
||
<section className="grid two">
|
||
<article className="card span2">
|
||
<h3>Граница подготовки Fabric</h3>
|
||
<p className="muted">
|
||
Этот экран показывает synthetic/control-plane подготовку и C17Z11 boundary: production forwarding доступен только для route-bound
|
||
`fabric.control` при явном gate. Service traffic, RDP, VPN и произвольный relay здесь не включены.
|
||
</p>
|
||
<div className="signalStrip">
|
||
<Signal label="Synthetic configs" value={`${syntheticConfigEnabledCount}/${nodes.length}`} />
|
||
<Signal label="Routes" value={String(syntheticRouteCount)} />
|
||
<Signal label="Endpoints / candidates" value={`${syntheticPeerEndpointCount}/${syntheticCandidateCount}`} />
|
||
<Signal label="Peer dir / seeds" value={`${syntheticPeerDirectoryCount}/${syntheticRecoverySeedCount}`} />
|
||
<Signal label="Scoped production flag" value={productionForwardingConfigCount === 0 ? "false" : `true:${productionForwardingConfigCount}`} />
|
||
</div>
|
||
</article>
|
||
<article className="card">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.fabricEntryPoints}</h3>
|
||
<p className="muted">{t.fabricEntryPointHelp}</p>
|
||
</div>
|
||
<span className="pill">{entryPoints.length}</span>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
{t.endpointName}
|
||
<input value={entryPointForm.name} onChange={(event) => setEntryPointForm({ ...entryPointForm, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
{t.endpointType}
|
||
<select
|
||
value={entryPointForm.endpointType}
|
||
onChange={(event) => setEntryPointForm({ ...entryPointForm, endpointType: event.target.value })}
|
||
>
|
||
<option value="client_access">client_access</option>
|
||
<option value="admin">admin</option>
|
||
<option value="api">api</option>
|
||
<option value="other">other</option>
|
||
</select>
|
||
</label>
|
||
<label className="span2">
|
||
{t.publicEndpoint}
|
||
<input
|
||
placeholder="wss://entry.example.com"
|
||
value={entryPointForm.publicEndpoint}
|
||
onChange={(event) => setEntryPointForm({ ...entryPointForm, publicEndpoint: event.target.value })}
|
||
/>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
disabled={!entryPointForm.name.trim()}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
await client.createFabricEntryPoint(selectedClusterId, {
|
||
name: entryPointForm.name,
|
||
endpointType: entryPointForm.endpointType,
|
||
publicEndpoint: entryPointForm.publicEndpoint || null,
|
||
});
|
||
setEntryPointForm({ name: "", endpointType: "client_access", publicEndpoint: "" });
|
||
}, "Точка входа создана.")
|
||
}
|
||
>
|
||
{t.createEntryPoint}
|
||
</button>
|
||
</div>
|
||
<div className="stack">
|
||
{entryPoints.map((item) => {
|
||
const assignedNodes = entryPointNodesById[item.id] || [];
|
||
return (
|
||
<section className="nodePanel" key={item.id}>
|
||
<div className="cardHead">
|
||
<div>
|
||
<h4>{item.name}</h4>
|
||
<p className="muted">
|
||
{item.endpoint_type} · {item.public_endpoint || t.addressNotSet}
|
||
</p>
|
||
</div>
|
||
<Status value={item.status} />
|
||
</div>
|
||
<h5>{t.endpointNodes}</h5>
|
||
{assignedNodes.length === 0 ? (
|
||
<p className="muted">{t.assignedNodesEmpty}</p>
|
||
) : (
|
||
<div className="membershipList">
|
||
{assignedNodes.map((assignment) => (
|
||
<span className={assignment.status === "active" ? "pill good" : "pill"} key={`${item.id}-${assignment.node_id}`}>
|
||
{nodeName(nodes, assignment.node_id)} · {statusLabel(assignment.status)} · p{assignment.priority}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="actions">
|
||
<select
|
||
value={entryPointNodeDrafts[item.id] || ""}
|
||
onChange={(event) => setEntryPointNodeDrafts({ ...entryPointNodeDrafts, [item.id]: event.target.value })}
|
||
>
|
||
<option value="">{t.selectNode}</option>
|
||
{nodes.map((node) => (
|
||
<option key={node.id} value={node.id}>
|
||
{node.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
disabled={!entryPointNodeDrafts[item.id]}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.setFabricEntryPointNode(selectedClusterId, item.id, entryPointNodeDrafts[item.id], { status: "active" }),
|
||
"Узел назначен точке входа.",
|
||
)
|
||
}
|
||
>
|
||
{t.assignEndpointNode}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
{entryPoints.length === 0 && <EmptyState title={t.fabricEntryPoints} text={t.entryPointsEmpty} />}
|
||
</div>
|
||
</article>
|
||
<article className="card">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.fabricEgressPools}</h3>
|
||
<p className="muted">{t.fabricEgressPoolHelp}</p>
|
||
</div>
|
||
<span className="pill">{egressPools.length}</span>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
{t.endpointName}
|
||
<input value={egressPoolForm.name} onChange={(event) => setEgressPoolForm({ ...egressPoolForm, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
{t.description}
|
||
<input
|
||
value={egressPoolForm.description}
|
||
onChange={(event) => setEgressPoolForm({ ...egressPoolForm, description: event.target.value })}
|
||
/>
|
||
</label>
|
||
<label className="span2">
|
||
{t.routeScope}
|
||
<textarea
|
||
rows={5}
|
||
value={egressPoolForm.routeScope}
|
||
onChange={(event) => setEgressPoolForm({ ...egressPoolForm, routeScope: event.target.value })}
|
||
/>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
disabled={!egressPoolForm.name.trim()}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const routeScope = parseJSONObject(egressPoolForm.routeScope, "Route scope JSON");
|
||
await client.createFabricEgressPool(selectedClusterId, {
|
||
name: egressPoolForm.name,
|
||
description: egressPoolForm.description || null,
|
||
routeScope,
|
||
});
|
||
setEgressPoolForm({ name: "", description: "", routeScope: "{\n \"routes\": []\n}" });
|
||
}, "Выходная зона создана.")
|
||
}
|
||
>
|
||
{t.createEgressPool}
|
||
</button>
|
||
</div>
|
||
<div className="stack">
|
||
{egressPools.map((item) => {
|
||
const assignedNodes = egressPoolNodesById[item.id] || [];
|
||
return (
|
||
<section className="nodePanel" key={item.id}>
|
||
<div className="cardHead">
|
||
<div>
|
||
<h4>{item.name}</h4>
|
||
<p className="muted">{item.description || t.descriptionNotSet}</p>
|
||
</div>
|
||
<Status value={item.status} />
|
||
</div>
|
||
<p className="muted">{JSON.stringify(item.route_scope || {})}</p>
|
||
<h5>{t.endpointNodes}</h5>
|
||
{assignedNodes.length === 0 ? (
|
||
<p className="muted">{t.assignedNodesEmpty}</p>
|
||
) : (
|
||
<div className="membershipList">
|
||
{assignedNodes.map((assignment) => (
|
||
<span className={assignment.status === "active" ? "pill good" : "pill"} key={`${item.id}-${assignment.node_id}`}>
|
||
{nodeName(nodes, assignment.node_id)} · {statusLabel(assignment.status)} · p{assignment.priority}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="actions">
|
||
<select
|
||
value={egressPoolNodeDrafts[item.id] || ""}
|
||
onChange={(event) => setEgressPoolNodeDrafts({ ...egressPoolNodeDrafts, [item.id]: event.target.value })}
|
||
>
|
||
<option value="">{t.selectNode}</option>
|
||
{nodes.map((node) => (
|
||
<option key={node.id} value={node.id}>
|
||
{node.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
disabled={!egressPoolNodeDrafts[item.id]}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.setFabricEgressPoolNode(selectedClusterId, item.id, egressPoolNodeDrafts[item.id], { status: "active" }),
|
||
"Узел назначен выходной зоне.",
|
||
)
|
||
}
|
||
>
|
||
{t.assignEndpointNode}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
{egressPools.length === 0 && <EmptyState title={t.fabricEgressPools} text={t.egressPoolsEmpty} />}
|
||
</div>
|
||
</article>
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>{t.fabricMap}</h3>
|
||
<p className="muted">
|
||
Визуальный слой показывает, какие узлы живы, какие сервисы на них назначены и какие тестовые наблюдения связей проходят между ними.
|
||
</p>
|
||
</div>
|
||
<Status value={platformTestingFlag?.synthetic_links_enabled ? "enabled" : "disabled"} />
|
||
</div>
|
||
<FabricTopology
|
||
nodes={nodes}
|
||
links={meshLinks}
|
||
entryPoints={entryPoints}
|
||
entryPointNodesById={entryPointNodesById}
|
||
egressPools={egressPools}
|
||
egressPoolNodesById={egressPoolNodesById}
|
||
rolesByNode={rolesByNode}
|
||
workloadsByNode={workloadsByNode}
|
||
telemetryByNode={telemetryByNode}
|
||
labels={t}
|
||
emptyText={t.noLinks}
|
||
/>
|
||
</article>
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Synthetic mesh config</h3>
|
||
<p className="muted">
|
||
Node-scoped config from Control Plane. Endpoint candidates and scoring inputs are visible to the platform owner only; production
|
||
forwarding for service traffic must remain disabled here.
|
||
</p>
|
||
</div>
|
||
<span className={productionForwardingConfigCount === 0 ? "pill good" : "pill bad"}>
|
||
production_forwarding={productionForwardingConfigCount === 0 ? "false" : "true"}
|
||
</span>
|
||
</div>
|
||
<DataTable
|
||
columns={[
|
||
"узел",
|
||
"config",
|
||
"routes",
|
||
"peer endpoints",
|
||
"candidates",
|
||
"peer dir",
|
||
"recovery seeds",
|
||
"rendezvous leases",
|
||
"relay policy",
|
||
"path decisions",
|
||
"authority",
|
||
"scoped production",
|
||
]}
|
||
rows={nodes.map((node) => {
|
||
const config = syntheticMeshConfigsByNode[node.id];
|
||
return [
|
||
node.name,
|
||
config ? (config.enabled ? "enabled" : "disabled") : "не загружен",
|
||
String(config?.routes.length ?? 0),
|
||
String(Object.keys(config?.peer_endpoints || {}).length),
|
||
String(config ? countPeerEndpointCandidates(config) : 0),
|
||
String(config?.peer_directory?.length ?? 0),
|
||
String(config?.recovery_seeds?.length ?? 0),
|
||
String(config?.rendezvous_leases?.length ?? 0),
|
||
rendezvousRelayPolicySummary(config),
|
||
routePathDecisionSummary(config),
|
||
config?.authority_required ? shortId(config.authority_signature?.key_fingerprint) : "не требуется",
|
||
config?.production_forwarding ? "true" : "false",
|
||
];
|
||
})}
|
||
/>
|
||
<p className="muted">
|
||
Health-aware scoring не выбирает service route и не открывает service-соединения. C17Z19 показывает control-plane route/path
|
||
decisions, route generation status, synthetic route-health effective path и relay feedback scoring, но не переносит
|
||
RDP/VPN/file/video/service payload.
|
||
</p>
|
||
</article>
|
||
<article className="card span2">
|
||
<h3>{t.servicePlacement}</h3>
|
||
<DataTable
|
||
columns={["узел", "здоровье", "роли", "желаемые / reported сервисы", "последний heartbeat"]}
|
||
rows={nodes.map((node) => [
|
||
node.name,
|
||
node.health_status,
|
||
summarizeRoles(rolesByNode[node.id] || []),
|
||
summarizeWorkloads(workloadsByNode[node.id] || []),
|
||
formatDate((heartbeatsByNode[node.id] || [])[0]?.observed_at || node.last_seen_at),
|
||
])}
|
||
/>
|
||
</article>
|
||
<article className="card">
|
||
<h3>{t.trafficFlow}</h3>
|
||
<DataTable
|
||
columns={["источник", "цель", "тип", "route/path", "статус", "задержка", "качество", "наблюдение"]}
|
||
rows={meshLinks.map((link) => [
|
||
nodeName(nodes, link.source_node_id),
|
||
nodeName(nodes, link.target_node_id),
|
||
meshLinkObservationLabel(link),
|
||
meshLinkRoutePathSummary(link, nodes),
|
||
link.link_status,
|
||
link.latency_ms == null ? "н/д" : `${link.latency_ms} мс`,
|
||
link.quality_score == null ? "н/д" : `${link.quality_score}/100`,
|
||
formatDate(link.observed_at),
|
||
])}
|
||
/>
|
||
</article>
|
||
<article className="card">
|
||
<h3>Политики QoS</h3>
|
||
<DataTable
|
||
columns={["класс", "приоритет", "надежность", "политика сброса"]}
|
||
rows={qosPolicies.map((policy) => [
|
||
policy.service_class,
|
||
String(policy.priority),
|
||
policy.reliability_mode,
|
||
policy.drop_policy,
|
||
])}
|
||
/>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "vpn" && (
|
||
<section className="grid two">
|
||
<article className="card">
|
||
<h3>Создать желаемое состояние VPN-подключения</h3>
|
||
<p className="muted">Только control-plane. Здесь не выполняются TUN/TAP, маршруты, DNS, firewall, QoS или packet forwarding.</p>
|
||
<FormGrid>
|
||
<label>
|
||
ID организации
|
||
<input value={vpnForm.organizationId} onChange={(event) => setVPNForm({ ...vpnForm, organizationId: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Название
|
||
<input value={vpnForm.name} onChange={(event) => setVPNForm({ ...vpnForm, name: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Протокол
|
||
<select value={vpnForm.protocolFamily} onChange={(event) => setVPNForm({ ...vpnForm, protocolFamily: event.target.value })}>
|
||
<option value="generic">generic</option>
|
||
<option value="wireguard">wireguard</option>
|
||
<option value="ipsec">ipsec</option>
|
||
<option value="openvpn">openvpn</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Желаемое состояние
|
||
<select value={vpnForm.desiredState} onChange={(event) => setVPNForm({ ...vpnForm, desiredState: event.target.value })}>
|
||
<option value="disabled">выключено</option>
|
||
<option value="enabled">включено</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Ссылка на credential
|
||
<input value={vpnForm.credentialRef} onChange={(event) => setVPNForm({ ...vpnForm, credentialRef: event.target.value })} />
|
||
</label>
|
||
</FormGrid>
|
||
<label>
|
||
Целевой endpoint JSON
|
||
<textarea value={vpnForm.targetEndpointJson} onChange={(event) => setVPNForm({ ...vpnForm, targetEndpointJson: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Политика разрешенных узлов JSON
|
||
<textarea
|
||
value={vpnForm.allowedNodePolicyJson}
|
||
onChange={(event) => setVPNForm({ ...vpnForm, allowedNodePolicyJson: event.target.value })}
|
||
/>
|
||
</label>
|
||
<details>
|
||
<summary>Расширенные routing / QoS / placement JSON</summary>
|
||
<label>
|
||
Использование маршрутизации JSON
|
||
<textarea value={vpnForm.routingUsageJson} onChange={(event) => setVPNForm({ ...vpnForm, routingUsageJson: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Политика маршрута JSON
|
||
<textarea value={vpnForm.routePolicyJson} onChange={(event) => setVPNForm({ ...vpnForm, routePolicyJson: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Политика QoS JSON
|
||
<textarea value={vpnForm.qosPolicyJson} onChange={(event) => setVPNForm({ ...vpnForm, qosPolicyJson: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Политика размещения JSON
|
||
<textarea value={vpnForm.placementPolicyJson} onChange={(event) => setVPNForm({ ...vpnForm, placementPolicyJson: event.target.value })} />
|
||
</label>
|
||
</details>
|
||
<button
|
||
className="primary"
|
||
disabled={!selectedClusterId || !vpnForm.organizationId || !vpnForm.name}
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.createVPNConnection(selectedClusterId, {
|
||
organizationId: vpnForm.organizationId,
|
||
name: vpnForm.name,
|
||
protocolFamily: vpnForm.protocolFamily,
|
||
credentialRef: vpnForm.credentialRef || null,
|
||
desiredState: vpnForm.desiredState,
|
||
targetEndpoint: parseJSONObject(vpnForm.targetEndpointJson, "target endpoint"),
|
||
allowedNodePolicy: parseJSONObject(vpnForm.allowedNodePolicyJson, "allowed node policy"),
|
||
routingUsage: parseJSONArray(vpnForm.routingUsageJson, "routing usage"),
|
||
routePolicy: parseJSONObject(vpnForm.routePolicyJson, "route policy"),
|
||
qosPolicy: parseJSONObject(vpnForm.qosPolicyJson, "qos policy"),
|
||
placementPolicy: parseJSONObject(vpnForm.placementPolicyJson, "placement policy"),
|
||
}),
|
||
"Желаемое состояние VPN создано.",
|
||
)
|
||
}
|
||
>
|
||
Создать желаемое состояние VPN
|
||
</button>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>VPN-подключения</h3>
|
||
<p className="muted">Только cluster-managed желаемое состояние. Runtime здесь намеренно не реализован.</p>
|
||
</div>
|
||
<button
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const expired = await client.expireStaleVPNLeases(selectedClusterId);
|
||
setNotice(`Истекшие VPN lease: ${expired.length}.`);
|
||
}, "Stale VPN lease проверены.")
|
||
}
|
||
>
|
||
Проверить stale lease
|
||
</button>
|
||
</div>
|
||
<div className="stack">
|
||
{vpnConnections.map((connection) => (
|
||
<div className="vpnCard" key={connection.id}>
|
||
<div>
|
||
<strong>{connection.name}</strong>
|
||
<p className="muted">
|
||
{connection.protocol_family} / {connection.mode} / организация {shortId(connection.organization_id)}
|
||
</p>
|
||
<Status value={connection.desired_state} />
|
||
<Status value={connection.status} />
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="Секрет" value={connection.credential_ref ? "задан" : "не задан"} />
|
||
<StateLine label="Активный lease" value={vpnLeases[connection.id] ? shortId(vpnLeases[connection.id]?.owner_node_id) : "нет"} />
|
||
<StateLine label="Обновлено" value={formatDate(connection.updated_at)} />
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
disabled={connection.desired_state === "enabled"}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.updateVPNConnectionDesiredState(selectedClusterId, connection.id, "enabled"),
|
||
"Желаемое состояние VPN включено.",
|
||
)
|
||
}
|
||
>
|
||
Включить
|
||
</button>
|
||
<button
|
||
disabled={connection.desired_state === "disabled"}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.updateVPNConnectionDesiredState(selectedClusterId, connection.id, "disabled"),
|
||
"Желаемое состояние VPN выключено.",
|
||
)
|
||
}
|
||
>
|
||
Выключить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{vpnConnections.length === 0 && <EmptyState title="Нет желаемого состояния VPN" text="Control-plane записи C18 появятся здесь." />}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "org-safe" && (
|
||
<section className="card">
|
||
<h3>Безопасная проекция организации</h3>
|
||
<p className="muted">Это preview tenant-safe данных. Здесь нельзя показывать core mesh topology, peer cache, route cache или секреты.</p>
|
||
<div className="inlineForm">
|
||
<input value={organizationId} onChange={(event) => setOrganizationId(event.target.value)} placeholder="UUID организации" />
|
||
<button
|
||
disabled={!organizationId}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
setOrganizationSummary(await client.getOrganizationAdminSummary(organizationId));
|
||
}, "Сводка организации загружена.")
|
||
}
|
||
>
|
||
Загрузить безопасную сводку
|
||
</button>
|
||
</div>
|
||
{organizationSummary && (
|
||
<div className="grid three">
|
||
<Metric label="Ресурсы" value={organizationSummary.resource_count} tone="steel" />
|
||
<Metric label="Активные сессии" value={organizationSummary.active_session_count} tone="green" />
|
||
<article className="card">
|
||
<h4>Раскрытие topology</h4>
|
||
<p>{organizationSummary.topology_exposure}</p>
|
||
</article>
|
||
<article className="card span3">
|
||
<h4>Сервисные точки входа</h4>
|
||
<DataTable columns={["протокол", "количество"]} rows={organizationSummary.service_endpoints.map((item) => [item.protocol, String(item.count)])} />
|
||
</article>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "audit" && (
|
||
<section className="card">
|
||
<h3>Аудит кластера</h3>
|
||
<DataTable
|
||
columns={["событие", "цель", "actor", "создано"]}
|
||
rows={audit.map((event) => [
|
||
event.event_type,
|
||
`${event.target_type}${event.target_id ? `:${shortId(event.target_id)}` : ""}`,
|
||
event.actor_user_id ? shortId(event.actor_user_id) : "system",
|
||
formatDate(event.created_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
)}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function Metric({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||
return (
|
||
<article className={`metric ${tone}`}>
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function Signal({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="signal">
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Status({ value }: { value: string }) {
|
||
return <span className={`status ${value.replace(/_/g, "-")}`}>{statusLabel(value)}</span>;
|
||
}
|
||
|
||
function StateLine({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="stateLine">
|
||
<span>{label}</span>
|
||
<strong>{value}</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ title, text }: { title: string; text: string }) {
|
||
return (
|
||
<article className="empty">
|
||
<h3>{title}</h3>
|
||
<p>{text}</p>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function TelemetryStrip({ items, emptyText }: { items: NodeTelemetryObservation[]; emptyText: string }) {
|
||
if (items.length === 0) {
|
||
return <EmptyState title={emptyText} text="Тестовая телеметрия появится здесь после отчета node-agent." />;
|
||
}
|
||
const ordered = [...items].reverse().slice(-24);
|
||
const latest = items[0];
|
||
const maxMemory = Math.max(...ordered.map((item) => item.memory_used_bytes || 0), 1);
|
||
return (
|
||
<div className="telemetryBox">
|
||
<div className="signalStrip compact">
|
||
<Signal label="Память" value={`${formatBytes(latest.memory_used_bytes)} / ${formatBytes(latest.memory_total_bytes)}`} />
|
||
<Signal label="Процессор" value={latest.cpu_percent == null ? "н/д" : `${latest.cpu_percent.toFixed(1)}%`} />
|
||
<Signal label="Процессы" value={latest.process_count == null ? "н/д" : String(latest.process_count)} />
|
||
<Signal label="Обновлено" value={formatDate(latest.observed_at)} />
|
||
</div>
|
||
<div className="sparkline" aria-label="memory telemetry">
|
||
{ordered.map((item) => (
|
||
<span key={item.id} style={{ height: `${Math.max(8, Math.round(((item.memory_used_bytes || 0) / maxMemory) * 100))}%` }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FabricTopology({
|
||
nodes,
|
||
links,
|
||
entryPoints,
|
||
entryPointNodesById,
|
||
egressPools,
|
||
egressPoolNodesById,
|
||
rolesByNode,
|
||
workloadsByNode,
|
||
telemetryByNode,
|
||
labels,
|
||
emptyText,
|
||
}: {
|
||
nodes: ClusterNode[];
|
||
links: MeshLink[];
|
||
entryPoints: FabricEntryPoint[];
|
||
entryPointNodesById: Record<string, FabricEntryPointNode[]>;
|
||
egressPools: FabricEgressPool[];
|
||
egressPoolNodesById: Record<string, FabricEgressPoolNode[]>;
|
||
rolesByNode: Record<string, RoleAssignment[]>;
|
||
workloadsByNode: Record<string, WorkloadStatus[]>;
|
||
telemetryByNode: Record<string, NodeTelemetryObservation[]>;
|
||
labels: Record<string, string>;
|
||
emptyText: string;
|
||
}) {
|
||
if (nodes.length === 0) {
|
||
return <EmptyState title="Нет узлов" text="Одобренные node-agent появятся на карте после первого heartbeat." />;
|
||
}
|
||
|
||
const latestLinks = latestLinkObservations(links);
|
||
const nodePositions = new Map(nodes.map((node, index) => [node.id, topologyPoint(500, index, nodes.length, 125, 500)] as const));
|
||
const entryPositions = new Map(entryPoints.map((entryPoint, index) => [entryPoint.id, topologyPoint(160, index, entryPoints.length, 145, 480)] as const));
|
||
const egressPositions = new Map(egressPools.map((pool, index) => [pool.id, topologyPoint(840, index, egressPools.length, 145, 480)] as const));
|
||
const activeEntryAssignments = entryPoints.flatMap((entryPoint) =>
|
||
(entryPointNodesById[entryPoint.id] || [])
|
||
.filter((assignment) => assignment.status !== "disabled")
|
||
.map((assignment) => ({ entryPoint, assignment })),
|
||
);
|
||
const activeEgressAssignments = egressPools.flatMap((pool) =>
|
||
(egressPoolNodesById[pool.id] || [])
|
||
.filter((assignment) => assignment.status !== "disabled")
|
||
.map((assignment) => ({ pool, assignment })),
|
||
);
|
||
const placementCount = activeEntryAssignments.length + activeEgressAssignments.length;
|
||
|
||
return (
|
||
<div className="topologyShell">
|
||
<svg className="topologySvg" viewBox="0 0 1000 640" role="img" aria-label="Карта трафика узлов Fabric">
|
||
<defs>
|
||
<marker id="arrow" markerHeight="8" markerWidth="8" orient="auto" refX="7" refY="4">
|
||
<path d="M0,0 L8,4 L0,8 Z" fill="currentColor" />
|
||
</marker>
|
||
</defs>
|
||
<rect x="36" y="58" width="248" height="522" rx="30" className="topologyZone ingress" />
|
||
<rect x="322" y="58" width="356" height="522" rx="30" className="topologyZone core" />
|
||
<rect x="716" y="58" width="248" height="522" rx="30" className="topologyZone egress" />
|
||
<text x="160" y="98" className="topologyLayerLabel">
|
||
{labels.fabricIngressLayer}
|
||
</text>
|
||
<text x="500" y="98" className="topologyLayerLabel">
|
||
{labels.fabricNodeLayer}
|
||
</text>
|
||
<text x="840" y="98" className="topologyLayerLabel">
|
||
{labels.fabricEgressLayer}
|
||
</text>
|
||
|
||
{activeEntryAssignments.map(({ entryPoint, assignment }) => {
|
||
const source = entryPositions.get(entryPoint.id);
|
||
const target = nodePositions.get(assignment.node_id);
|
||
if (!source || !target) {
|
||
return null;
|
||
}
|
||
return (
|
||
<line
|
||
key={`entry-${entryPoint.id}-${assignment.node_id}`}
|
||
x1={source.x + 78}
|
||
y1={source.y}
|
||
x2={target.x - 62}
|
||
y2={target.y}
|
||
className={`topologyPlacementLink ${assignment.status === "active" ? "good" : "weak"}`}
|
||
markerEnd="url(#arrow)"
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{activeEgressAssignments.map(({ pool, assignment }) => {
|
||
const source = nodePositions.get(assignment.node_id);
|
||
const target = egressPositions.get(pool.id);
|
||
if (!source || !target) {
|
||
return null;
|
||
}
|
||
return (
|
||
<line
|
||
key={`egress-${pool.id}-${assignment.node_id}`}
|
||
x1={source.x + 62}
|
||
y1={source.y}
|
||
x2={target.x - 78}
|
||
y2={target.y}
|
||
className={`topologyPlacementLink ${assignment.status === "active" ? "good" : "weak"}`}
|
||
markerEnd="url(#arrow)"
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{latestLinks.map((link) => {
|
||
const source = nodePositions.get(link.source_node_id);
|
||
const target = nodePositions.get(link.target_node_id);
|
||
if (!source || !target) {
|
||
return null;
|
||
}
|
||
const midX = (source.x + target.x) / 2;
|
||
const midY = (source.y + target.y) / 2;
|
||
return (
|
||
<g key={link.id || `${link.source_node_id}-${link.target_node_id}`}>
|
||
<line
|
||
x1={source.x}
|
||
y1={source.y}
|
||
x2={target.x}
|
||
y2={target.y}
|
||
className={`topologyLink ${linkTone(link)}`}
|
||
markerEnd="url(#arrow)"
|
||
/>
|
||
<text x={midX} y={midY - 8} className="topologyLinkLabel">
|
||
{meshLinkTopologyLabel(link)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{entryPoints.map((entryPoint) => {
|
||
const point = entryPositions.get(entryPoint.id)!;
|
||
const count = (entryPointNodesById[entryPoint.id] || []).length;
|
||
return (
|
||
<g key={entryPoint.id} className="topologyEndpoint">
|
||
<rect x={point.x - 86} y={point.y - 36} width="172" height="72" rx="20" className={`topologyEndpointRect ${entryPoint.status}`} />
|
||
<text x={point.x} y={point.y - 8} className="topologyEndpointName">
|
||
{trimNodeName(entryPoint.name)}
|
||
</text>
|
||
<text x={point.x} y={point.y + 15} className="topologyEndpointMeta">
|
||
{entryPoint.endpoint_type} · {count}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{nodes.map((node) => {
|
||
const point = nodePositions.get(node.id)!;
|
||
const latestTelemetry = (telemetryByNode[node.id] || [])[0];
|
||
return (
|
||
<g key={node.id} className="topologyNode">
|
||
<circle cx={point.x} cy={point.y} r="54" className={`topologyNodeCircle ${node.health_status}`} />
|
||
<text x={point.x} y={point.y - 12} className="topologyNodeName">
|
||
{trimNodeName(node.name)}
|
||
</text>
|
||
<text x={point.x} y={point.y + 10} className="topologyNodeMeta">
|
||
{(rolesByNode[node.id] || []).length} ролей / {(workloadsByNode[node.id] || []).length} серв.
|
||
</text>
|
||
<text x={point.x} y={point.y + 31} className="topologyNodeMeta">
|
||
{formatBytes(latestTelemetry?.memory_used_bytes)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{egressPools.map((pool) => {
|
||
const point = egressPositions.get(pool.id)!;
|
||
const count = (egressPoolNodesById[pool.id] || []).length;
|
||
return (
|
||
<g key={pool.id} className="topologyEndpoint">
|
||
<rect x={point.x - 86} y={point.y - 36} width="172" height="72" rx="20" className={`topologyEndpointRect ${pool.status}`} />
|
||
<text x={point.x} y={point.y - 8} className="topologyEndpointName">
|
||
{trimNodeName(pool.name)}
|
||
</text>
|
||
<text x={point.x} y={point.y + 15} className="topologyEndpointMeta">
|
||
egress · {count}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{latestLinks.length === 0 && placementCount === 0 && (
|
||
<text x="500" y="606" className="topologyEmpty">
|
||
{emptyText}
|
||
</text>
|
||
)}
|
||
</svg>
|
||
<div className="topologyLegend">
|
||
<span>
|
||
<i className="legendLine placement" /> {labels.placementIntent}: {placementCount}
|
||
</span>
|
||
<span>
|
||
<i className="legendLine observed" /> {labels.observedPeerLinks}: {latestLinks.length}
|
||
</span>
|
||
</div>
|
||
<div className="serviceTags">
|
||
{entryPoints.map((entryPoint) => (
|
||
<div className="serviceTag" key={entryPoint.id}>
|
||
<strong>{entryPoint.name}</strong>
|
||
<span>{entryPoint.endpoint_type}</span>
|
||
<small>{(entryPointNodesById[entryPoint.id] || []).map((item) => nodeName(nodes, item.node_id)).join(", ") || labels.assignedNodesEmpty}</small>
|
||
</div>
|
||
))}
|
||
{nodes.map((node) => (
|
||
<div className="serviceTag" key={node.id}>
|
||
<strong>{node.name}</strong>
|
||
<span>{node.health_status}</span>
|
||
<small>{summarizeWorkloads(workloadsByNode[node.id] || [])}</small>
|
||
</div>
|
||
))}
|
||
{egressPools.map((pool) => (
|
||
<div className="serviceTag" key={pool.id}>
|
||
<strong>{pool.name}</strong>
|
||
<span>{pool.status}</span>
|
||
<small>{(egressPoolNodesById[pool.id] || []).map((item) => nodeName(nodes, item.node_id)).join(", ") || labels.assignedNodesEmpty}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function topologyPoint(x: number, index: number, count: number, top: number, bottom: number) {
|
||
if (count <= 1) {
|
||
return { x, y: Math.round((top + bottom) / 2) };
|
||
}
|
||
return { x, y: Math.round(top + ((bottom - top) * index) / (count - 1)) };
|
||
}
|
||
|
||
function NodeGroupTree({ groups, nodes, labels }: { groups: ClusterNodeGroup[]; nodes: ClusterNode[]; labels: Record<string, string> }) {
|
||
const rootGroups = groups
|
||
.filter((group) => !group.parent_group_id)
|
||
.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
|
||
const ungroupedNodes = nodes.filter((node) => !node.node_group_id);
|
||
|
||
if (groups.length === 0 && nodes.length === 0) {
|
||
return <EmptyState title="Нет узлов" text="Одобренные node-agent появятся здесь." />;
|
||
}
|
||
|
||
return (
|
||
<div className="treeList">
|
||
{rootGroups.map((group) => (
|
||
<NodeGroupBranch key={group.id} group={group} groups={groups} nodes={nodes} depth={0} />
|
||
))}
|
||
{ungroupedNodes.length > 0 && (
|
||
<section className="treeBranch">
|
||
<h4>{labels.ungroupedNodes}</h4>
|
||
<div className="treeNodeList">
|
||
{ungroupedNodes.map((node) => (
|
||
<span className="pill" key={node.id}>
|
||
{node.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeGroupBranch({ group, groups, nodes, depth }: { group: ClusterNodeGroup; groups: ClusterNodeGroup[]; nodes: ClusterNode[]; depth: number }) {
|
||
const childGroups = groups
|
||
.filter((item) => item.parent_group_id === group.id)
|
||
.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
|
||
const groupedNodes = nodes.filter((node) => node.node_group_id === group.id);
|
||
|
||
return (
|
||
<section className="treeBranch" style={{ marginLeft: `${depth * 18}px` }}>
|
||
<h4>
|
||
{group.name}
|
||
<span className="pill">{groupedNodes.length}</span>
|
||
</h4>
|
||
{group.description && <p className="muted">{group.description}</p>}
|
||
<div className="treeNodeList">
|
||
{groupedNodes.map((node) => (
|
||
<span className="pill good" key={node.id}>
|
||
{node.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{childGroups.map((child) => (
|
||
<NodeGroupBranch key={child.id} group={child} groups={groups} nodes={nodes} depth={depth + 1} />
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function FormGrid({ children }: { children: ReactNode }) {
|
||
return <div className="formGrid">{children}</div>;
|
||
}
|
||
|
||
function DataTable({ columns, rows }: { columns: string[]; rows: ReactNode[][] }) {
|
||
if (rows.length === 0) {
|
||
return <EmptyState title="Нет данных" text="В текущей области пока нечего показать." />;
|
||
}
|
||
return (
|
||
<div className="tableWrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
{columns.map((column) => (
|
||
<th key={column}>{column}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => (
|
||
<tr key={index}>
|
||
{row.map((cell, cellIndex) => (
|
||
<td key={`${index}-${cellIndex}`}>{cell}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function parseJSONObject(raw: string, label: string): Record<string, unknown> {
|
||
const parsed = JSON.parse(raw || "{}") as unknown;
|
||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||
throw new Error(`${label}: требуется JSON object.`);
|
||
}
|
||
return parsed as Record<string, unknown>;
|
||
}
|
||
|
||
function parseJSONArray(raw: string, label: string): unknown[] {
|
||
const parsed = JSON.parse(raw || "[]") as unknown;
|
||
if (!Array.isArray(parsed)) {
|
||
throw new Error(`${label}: требуется JSON array.`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function summarizeRoles(roles: RoleAssignment[]) {
|
||
if (roles.length === 0) {
|
||
return "активные роли не назначены";
|
||
}
|
||
return roles.map((role) => `${role.role}${role.organization_id ? ` @ ${shortId(role.organization_id)}` : ""}`).join(", ");
|
||
}
|
||
|
||
function summarizeWorkloads(workloads: WorkloadStatus[]) {
|
||
if (workloads.length === 0) {
|
||
return "нет сервисов";
|
||
}
|
||
return workloads.map((workload) => `${workload.service_type}:${workload.reported_state}`).join(", ");
|
||
}
|
||
|
||
function latestLinkObservations(links: MeshLink[]) {
|
||
const byPair = new Map<string, MeshLink>();
|
||
for (const link of links) {
|
||
const key = `${link.source_node_id}->${link.target_node_id}:${meshLinkObservationKey(link)}`;
|
||
const existing = byPair.get(key);
|
||
if (!existing || new Date(link.observed_at).getTime() > new Date(existing.observed_at).getTime()) {
|
||
byPair.set(key, link);
|
||
}
|
||
}
|
||
return [...byPair.values()];
|
||
}
|
||
|
||
function meshLinkObservationKey(link: MeshLink) {
|
||
const observationType = meshLinkMetadataString(link, "observation_type") || "default";
|
||
if (observationType === "synthetic_route_health") {
|
||
return `${observationType}:${meshLinkMetadataString(link, "route_id") || link.id}`;
|
||
}
|
||
if (observationType === "peer_connection_manager") {
|
||
return `${observationType}:${meshLinkMetadataString(link, "transport_mode")}:${meshLinkMetadataString(link, "relay_node_id")}`;
|
||
}
|
||
return observationType;
|
||
}
|
||
|
||
function meshLinkObservationLabel(link: MeshLink) {
|
||
const observationType = meshLinkMetadataString(link, "observation_type");
|
||
if (observationType === "synthetic_route_health") {
|
||
const drift = link.metadata?.route_path_drift_detected === true ? "drift" : "ok";
|
||
const applied = link.metadata?.route_path_decision_applied === true ? "decision" : "route";
|
||
return `route-health ${applied} ${drift}`;
|
||
}
|
||
if (observationType === "peer_connection_manager") {
|
||
const mode = meshLinkMetadataString(link, "transport_mode") || "manager";
|
||
const state = meshLinkMetadataString(link, "connection_state");
|
||
return state ? `${mode} ${state}` : mode;
|
||
}
|
||
return observationType || "link";
|
||
}
|
||
|
||
function meshLinkRoutePathSummary(link: MeshLink, nodes: ClusterNode[]) {
|
||
const routeID = meshLinkMetadataString(link, "route_id");
|
||
const relayID = meshLinkMetadataString(link, "route_path_decision_selected_relay_id") || meshLinkMetadataString(link, "relay_node_id");
|
||
const hops = meshLinkMetadataStringArray(link, "expected_effective_hops");
|
||
const observed = meshLinkMetadataStringArray(link, "observed_ack_path");
|
||
const path = hops.length > 0 ? hops : observed;
|
||
const parts: string[] = [];
|
||
if (routeID) {
|
||
parts.push(shortId(routeID));
|
||
}
|
||
if (relayID) {
|
||
parts.push(`via ${shortId(relayID)}`);
|
||
}
|
||
if (path.length > 0) {
|
||
parts.push(path.map((id) => trimNodeName(nodeName(nodes, id))).join(" > "));
|
||
}
|
||
return parts.length > 0 ? parts.join(" / ") : "н/д";
|
||
}
|
||
|
||
function meshLinkTopologyLabel(link: MeshLink) {
|
||
if (meshLinkMetadataString(link, "observation_type") === "synthetic_route_health") {
|
||
return link.metadata?.route_path_drift_detected === true ? "drift" : "route";
|
||
}
|
||
return link.latency_ms == null ? "связь" : `${link.latency_ms}мс`;
|
||
}
|
||
|
||
function meshLinkMetadataString(link: MeshLink, key: string) {
|
||
const value = link.metadata?.[key];
|
||
return typeof value === "string" ? value : "";
|
||
}
|
||
|
||
function meshLinkMetadataStringArray(link: MeshLink, key: string) {
|
||
const value = link.metadata?.[key];
|
||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||
}
|
||
|
||
function countPeerEndpointCandidates(config: NodeSyntheticMeshConfig) {
|
||
return Object.values(config.peer_endpoint_candidates || {}).reduce((sum, candidates) => sum + candidates.length, 0);
|
||
}
|
||
|
||
function rendezvousRelayPolicySummary(config?: NodeSyntheticMeshConfig) {
|
||
const policy = config?.rendezvous_relay_policy;
|
||
if (!policy) {
|
||
return "none";
|
||
}
|
||
const parts = [`stale${policy.stale_relay_count}`, `wd${policy.withdrawn_lease_count}`, `repl${policy.replacement_lease_count}`];
|
||
if (policy.scoring_mode.includes("synthetic_route_health_feedback")) {
|
||
parts.push("rh feedback");
|
||
}
|
||
const selected = policy.decisions?.find((decision) => decision.selected_relay_id);
|
||
if (selected?.selected_relay_id) {
|
||
parts.push(`via ${shortId(selected.selected_relay_id)}`);
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function routePathDecisionSummary(config?: NodeSyntheticMeshConfig) {
|
||
const report = config?.route_path_decisions;
|
||
if (!report) {
|
||
return "none";
|
||
}
|
||
const parts = [`path${report.decision_count}`, `repl${report.replacement_decision_count}`];
|
||
const selected = report.decisions?.find((decision) => decision.selected_relay_id || decision.next_hop_id);
|
||
if (selected?.selected_relay_id) {
|
||
parts.push(`via ${shortId(selected.selected_relay_id)}`);
|
||
} else if (selected?.next_hop_id) {
|
||
parts.push(`next ${shortId(selected.next_hop_id)}`);
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function nodeName(nodes: ClusterNode[], id: string) {
|
||
return nodes.find((node) => node.id === id)?.name || shortId(id);
|
||
}
|
||
|
||
function groupPathLabel(group: ClusterNodeGroup, groups: ClusterNodeGroup[]) {
|
||
const byId = new Map(groups.map((item) => [item.id, item]));
|
||
const parts = [group.name];
|
||
let parentID = group.parent_group_id;
|
||
const seen = new Set<string>([group.id]);
|
||
while (parentID && !seen.has(parentID)) {
|
||
seen.add(parentID);
|
||
const parent = byId.get(parentID);
|
||
if (!parent) {
|
||
break;
|
||
}
|
||
parts.unshift(parent.name);
|
||
parentID = parent.parent_group_id;
|
||
}
|
||
return parts.join(" / ");
|
||
}
|
||
|
||
function groupPathLabelById(groupId: string, groups: ClusterNodeGroup[]) {
|
||
const group = groups.find((item) => item.id === groupId);
|
||
return group ? groupPathLabel(group, groups) : groupId;
|
||
}
|
||
|
||
function descendantGroupIds(groupId: string, groups: ClusterNodeGroup[]) {
|
||
const result: string[] = [];
|
||
const childrenByParent = new Map<string, ClusterNodeGroup[]>();
|
||
for (const group of groups) {
|
||
const parentKey = group.parent_group_id || "";
|
||
childrenByParent.set(parentKey, [...(childrenByParent.get(parentKey) || []), group]);
|
||
}
|
||
const visit = (parentId: string) => {
|
||
for (const child of childrenByParent.get(parentId) || []) {
|
||
result.push(child.id);
|
||
visit(child.id);
|
||
}
|
||
};
|
||
visit(groupId);
|
||
return result;
|
||
}
|
||
|
||
function buildNodeInventoryTreeRows(
|
||
entries: NodeInventoryEntry[],
|
||
groups: ClusterNodeGroup[],
|
||
selectedClusterId: string,
|
||
labels: Record<string, string>,
|
||
collapsedGroupKeys: Set<string>,
|
||
): NodeInventoryTreeRow[] {
|
||
const rows: NodeInventoryTreeRow[] = [];
|
||
const entriesByGroup = new Map<string, NodeInventoryEntry[]>();
|
||
const activeUngrouped: NodeInventoryEntry[] = [];
|
||
const outsideActiveCluster: NodeInventoryEntry[] = [];
|
||
|
||
for (const entry of entries) {
|
||
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
|
||
if (!activeMembership) {
|
||
outsideActiveCluster.push(entry);
|
||
continue;
|
||
}
|
||
const groupID = activeMembership.node.node_group_id;
|
||
if (!groupID) {
|
||
activeUngrouped.push(entry);
|
||
continue;
|
||
}
|
||
entriesByGroup.set(groupID, [...(entriesByGroup.get(groupID) || []), entry]);
|
||
}
|
||
|
||
const childrenByParent = new Map<string, ClusterNodeGroup[]>();
|
||
for (const group of groups) {
|
||
const parentKey = group.parent_group_id || "";
|
||
childrenByParent.set(parentKey, [...(childrenByParent.get(parentKey) || []), group]);
|
||
}
|
||
for (const childGroups of childrenByParent.values()) {
|
||
childGroups.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
|
||
}
|
||
|
||
const countByGroup = new Map<string, number>();
|
||
const countGroupEntries = (group: ClusterNodeGroup): number => {
|
||
const cached = countByGroup.get(group.id);
|
||
if (cached != null) {
|
||
return cached;
|
||
}
|
||
let count = entriesByGroup.get(group.id)?.length || 0;
|
||
for (const child of childrenByParent.get(group.id) || []) {
|
||
count += countGroupEntries(child);
|
||
}
|
||
countByGroup.set(group.id, count);
|
||
return count;
|
||
};
|
||
|
||
const appendGroup = (group: ClusterNodeGroup, depth: number): number => {
|
||
const ownEntries = [...(entriesByGroup.get(group.id) || [])].sort((left, right) => left.node.name.localeCompare(right.node.name));
|
||
const childGroups = childrenByParent.get(group.id) || [];
|
||
const rowKey = `group-${group.id}`;
|
||
const count = countGroupEntries(group);
|
||
rows.push({ kind: "group", key: rowKey, label: group.name, depth, count, groupId: group.id });
|
||
if (!collapsedGroupKeys.has(rowKey)) {
|
||
for (const entry of ownEntries) {
|
||
rows.push({ kind: "node", key: `node-${group.id}-${entry.node.id}`, entry, depth: depth + 1 });
|
||
}
|
||
for (const child of childGroups) {
|
||
appendGroup(child, depth + 1);
|
||
}
|
||
}
|
||
return count;
|
||
};
|
||
|
||
for (const group of childrenByParent.get("") || []) {
|
||
appendGroup(group, 0);
|
||
}
|
||
|
||
if (activeUngrouped.length > 0) {
|
||
const rowKey = "group-ungrouped";
|
||
rows.push({ kind: "group", key: rowKey, label: labels.ungroupedNodes, depth: 0, count: activeUngrouped.length });
|
||
if (!collapsedGroupKeys.has(rowKey)) {
|
||
for (const entry of activeUngrouped.sort((left, right) => left.node.name.localeCompare(right.node.name))) {
|
||
rows.push({ kind: "node", key: `node-ungrouped-${entry.node.id}`, entry, depth: 1 });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (outsideActiveCluster.length > 0) {
|
||
const rowKey = "group-outside-active-cluster";
|
||
rows.push({
|
||
kind: "group",
|
||
key: rowKey,
|
||
label: labels.notMemberOfActiveCluster,
|
||
depth: 0,
|
||
count: outsideActiveCluster.length,
|
||
});
|
||
if (!collapsedGroupKeys.has(rowKey)) {
|
||
for (const entry of outsideActiveCluster.sort((left, right) => left.node.name.localeCompare(right.node.name))) {
|
||
rows.push({ kind: "node", key: `node-outside-${entry.node.id}`, entry, depth: 1 });
|
||
}
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
function nodesWithRole(role: string, rolesByNode: Record<string, RoleAssignment[]>) {
|
||
return Object.entries(rolesByNode)
|
||
.filter(([, roles]) => roles.some((assignment) => assignment.role === role && assignment.status === "active"))
|
||
.map(([nodeId]) => nodeId);
|
||
}
|
||
|
||
function buildJoinTokenScope(input: { roles: string[]; ownershipType: string; purpose: string }): Record<string, unknown> {
|
||
return {
|
||
roles: input.roles,
|
||
ownership_type: input.ownershipType,
|
||
purpose: input.purpose.trim() || null,
|
||
approval: {
|
||
mode: "manual",
|
||
auto_approve: false,
|
||
role_assignment: "manual_after_approval",
|
||
},
|
||
source: "platform_owner_console",
|
||
};
|
||
}
|
||
|
||
function toggleArrayValue(values: string[], value: string) {
|
||
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
|
||
}
|
||
|
||
function groupNodeInventory(
|
||
entries: NodeInventoryEntry[],
|
||
selectedClusterId: string,
|
||
search: string,
|
||
groupBy: "membership" | "health" | "ownership" | "cluster_count",
|
||
language: Language,
|
||
) {
|
||
const normalizedSearch = search.trim().toLowerCase();
|
||
const groups = new Map<string, NodeInventoryEntry[]>();
|
||
|
||
for (const entry of entries) {
|
||
if (normalizedSearch && !nodeInventoryMatches(entry, normalizedSearch)) {
|
||
continue;
|
||
}
|
||
const key = nodeInventoryGroupLabel(entry, selectedClusterId, groupBy, language);
|
||
groups.set(key, [...(groups.get(key) || []), entry]);
|
||
}
|
||
|
||
return Array.from(groups.entries())
|
||
.map(([label, items]) => ({ label, items: items.sort((left, right) => left.node.name.localeCompare(right.node.name)) }))
|
||
.sort((left, right) => left.label.localeCompare(right.label));
|
||
}
|
||
|
||
function nodeInventoryMatches(entry: NodeInventoryEntry, search: string) {
|
||
const values = [
|
||
entry.node.name,
|
||
entry.node.node_key,
|
||
entry.node.health_status,
|
||
entry.node.ownership_type,
|
||
entry.node.reported_version || "",
|
||
...entry.memberships.flatMap((membership) => [membership.cluster.name, membership.cluster.slug, membership.node.membership_status]),
|
||
];
|
||
return values.some((value) => value.toLowerCase().includes(search));
|
||
}
|
||
|
||
function nodeInventoryGroupLabel(
|
||
entry: NodeInventoryEntry,
|
||
selectedClusterId: string,
|
||
groupBy: "membership" | "health" | "ownership" | "cluster_count",
|
||
language: Language,
|
||
) {
|
||
if (groupBy === "health") {
|
||
return statusLabel(entry.node.health_status);
|
||
}
|
||
if (groupBy === "ownership") {
|
||
return statusLabel(entry.node.ownership_type);
|
||
}
|
||
if (groupBy === "cluster_count") {
|
||
return clusterCountLabel(entry.memberships.length, language);
|
||
}
|
||
|
||
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
|
||
if (!activeMembership) {
|
||
return language === "en" ? "Not in active cluster" : "Не в активном кластере";
|
||
}
|
||
if (activeMembership.node.membership_status === "active") {
|
||
return language === "en" ? "In active cluster" : "В активном кластере";
|
||
}
|
||
return `${language === "en" ? "Membership" : "Участие"}: ${statusLabel(activeMembership.node.membership_status)}`;
|
||
}
|
||
|
||
function capabilityState(role: string, heartbeat?: NodeHeartbeat) {
|
||
const expectedKeys = capabilityKeysByRole[role] || [];
|
||
if (expectedKeys.length === 0 || !heartbeat) {
|
||
return "unknown";
|
||
}
|
||
const capabilities = heartbeat.capabilities || {};
|
||
return expectedKeys.some((key) => Boolean(capabilities[key])) ? "confirmed" : "missing";
|
||
}
|
||
|
||
function capabilityPillTone(role: string, heartbeat?: NodeHeartbeat) {
|
||
const state = capabilityState(role, heartbeat);
|
||
if (state === "confirmed") {
|
||
return "good";
|
||
}
|
||
if (state === "missing") {
|
||
return "bad";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function capabilityLabel(role: string, heartbeat: NodeHeartbeat | undefined, labels: Record<string, string>) {
|
||
const state = capabilityState(role, heartbeat);
|
||
if (state === "confirmed") {
|
||
return labels.capabilityConfirmed;
|
||
}
|
||
if (state === "missing") {
|
||
return labels.capabilityMissing;
|
||
}
|
||
return labels.capabilityUnknown;
|
||
}
|
||
|
||
function capabilityOptionLabel(role: string, heartbeat: NodeHeartbeat | undefined, language: Language) {
|
||
const state = capabilityState(role, heartbeat);
|
||
if (language === "en") {
|
||
return state === "confirmed" ? "capable" : state === "missing" ? "not reported" : "unknown";
|
||
}
|
||
return state === "confirmed" ? "подходит" : state === "missing" ? "не заявлено" : "неизвестно";
|
||
}
|
||
|
||
function meshRecoverySummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_peer_recovery_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const mode = typeof fields.mode === "string" ? fields.mode : "unknown";
|
||
const ready = typeof fields.ready_peer_count === "number" ? fields.ready_peer_count : null;
|
||
const target = typeof fields.target_ready_peers === "number" ? fields.target_ready_peers : null;
|
||
const deficit = typeof fields.deficit === "number" ? fields.deficit : 0;
|
||
const base = ready == null || target == null ? mode : `${mode} ${ready}/${target}`;
|
||
return deficit > 0 ? `${base} deficit ${deficit}` : base;
|
||
}
|
||
|
||
function meshConnectionIntentSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_peer_connection_intent_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return meshConnectionManagerSummary(heartbeat);
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const intentCount = typeof fields.intent_count === "number" ? fields.intent_count : 0;
|
||
const maintain = typeof fields.maintain_count === "number" ? fields.maintain_count : 0;
|
||
const recover = typeof fields.recover_count === "number" ? fields.recover_count : 0;
|
||
const rendezvous = typeof fields.rendezvous_required_count === "number" ? fields.rendezvous_required_count : 0;
|
||
const resolved = typeof fields.rendezvous_resolved_count === "number" ? fields.rendezvous_resolved_count : 0;
|
||
const relayControl = typeof fields.relay_control_count === "number" ? fields.relay_control_count : 0;
|
||
const rvPart = [`rv${rendezvous}`];
|
||
if (resolved > 0) {
|
||
rvPart.push(`ok${resolved}`);
|
||
}
|
||
if (relayControl > 0) {
|
||
rvPart.push(`relay${relayControl}`);
|
||
}
|
||
const base =
|
||
rendezvous > 0 || resolved > 0 || relayControl > 0
|
||
? `${intentCount} intents m${maintain}/r${recover} ${rvPart.join("/")}`
|
||
: `${intentCount} intents m${maintain}/r${recover}`;
|
||
const manager = meshConnectionManagerSummary(heartbeat);
|
||
return manager === "н/д" ? base : `${base}; ${manager}`;
|
||
}
|
||
|
||
function meshRendezvousLeaseSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_rendezvous_lease_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const total = typeof fields.lease_count === "number" ? fields.lease_count : 0;
|
||
const active = typeof fields.active_count === "number" ? fields.active_count : 0;
|
||
const relay = typeof fields.admitted_as_relay_count === "number" ? fields.admitted_as_relay_count : 0;
|
||
const peer = typeof fields.admitted_as_peer_count === "number" ? fields.admitted_as_peer_count : 0;
|
||
const renewal = typeof fields.renewal_needed_count === "number" ? fields.renewal_needed_count : 0;
|
||
const ready = typeof fields.relay_control_ready_count === "number" ? fields.relay_control_ready_count : 0;
|
||
const stale = typeof fields.stale_relay_count === "number" ? fields.stale_relay_count : 0;
|
||
const refreshAttempts = typeof fields.refresh_attempt_count === "number" ? fields.refresh_attempt_count : 0;
|
||
const refreshSuccess = typeof fields.refresh_success_count === "number" ? fields.refresh_success_count : 0;
|
||
const parts = [`lease ${active}/${total}`];
|
||
if (relay > 0) {
|
||
parts.push(`relay${relay}`);
|
||
}
|
||
if (peer > 0) {
|
||
parts.push(`peer${peer}`);
|
||
}
|
||
if (renewal > 0) {
|
||
parts.push(`renew${renewal}`);
|
||
}
|
||
if (stale > 0) {
|
||
parts.push(`stale${stale}`);
|
||
}
|
||
if (ready > 0) {
|
||
parts.push(`ready${ready}`);
|
||
}
|
||
if (refreshAttempts > 0) {
|
||
parts.push(`ref${refreshSuccess}/${refreshAttempts}`);
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function meshRoutePathDecisionSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_route_path_decision_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const total = typeof fields.decision_count === "number" ? fields.decision_count : 0;
|
||
const replacements = typeof fields.replacement_decision_count === "number" ? fields.replacement_decision_count : 0;
|
||
const local = typeof fields.local_effective_path_count === "number" ? fields.local_effective_path_count : 0;
|
||
const next = typeof fields.next_hop_available_count === "number" ? fields.next_hop_available_count : 0;
|
||
const withdrawn = typeof fields.withdrawn_local_relay_count === "number" ? fields.withdrawn_local_relay_count : 0;
|
||
const parts = [`path ${local}/${total}`];
|
||
if (replacements > 0) {
|
||
parts.push(`repl${replacements}`);
|
||
}
|
||
if (next > 0) {
|
||
parts.push(`next${next}`);
|
||
}
|
||
if (withdrawn > 0) {
|
||
parts.push(`wd${withdrawn}`);
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function meshRouteGenerationSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_route_generation_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const active = typeof fields.active_decision_count === "number" ? fields.active_decision_count : 0;
|
||
const applied = typeof fields.applied_decision_count === "number" ? fields.applied_decision_count : 0;
|
||
const withdrawn = typeof fields.withdrawn_decision_count === "number" ? fields.withdrawn_decision_count : 0;
|
||
const changed = fields.generation_changed === true;
|
||
const parts = [`gen ${active}`];
|
||
if (applied > 0) {
|
||
parts.push(`ap${applied}`);
|
||
}
|
||
if (withdrawn > 0) {
|
||
parts.push(`wd${withdrawn}`);
|
||
}
|
||
if (changed) {
|
||
parts.push("chg");
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function meshRouteHealthConfigSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_route_health_config_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const feedbackReport = heartbeat?.metadata?.mesh_route_health_feedback_refresh_report;
|
||
const feedbackFields =
|
||
feedbackReport && typeof feedbackReport === "object" && !Array.isArray(feedbackReport)
|
||
? (feedbackReport as Record<string, unknown>)
|
||
: {};
|
||
const routes = typeof fields.route_health_route_count === "number" ? fields.route_health_route_count : 0;
|
||
const applied = typeof fields.route_path_decision_applied_count === "number" ? fields.route_path_decision_applied_count : 0;
|
||
const replacements = typeof fields.replacement_route_health_route_count === "number" ? fields.replacement_route_health_route_count : 0;
|
||
const drift = typeof fields.route_health_decision_drift_candidate_count === "number" ? fields.route_health_decision_drift_candidate_count : 0;
|
||
const feedbackAttempts =
|
||
typeof feedbackFields.feedback_refresh_attempt_count === "number"
|
||
? feedbackFields.feedback_refresh_attempt_count
|
||
: typeof fields.feedback_refresh_attempt_count === "number"
|
||
? fields.feedback_refresh_attempt_count
|
||
: 0;
|
||
const feedbackSuccesses =
|
||
typeof feedbackFields.feedback_refresh_success_count === "number"
|
||
? feedbackFields.feedback_refresh_success_count
|
||
: typeof fields.feedback_refresh_success_count === "number"
|
||
? fields.feedback_refresh_success_count
|
||
: 0;
|
||
const feedbackSuppressed =
|
||
typeof feedbackFields.feedback_refresh_suppressed_count === "number"
|
||
? feedbackFields.feedback_refresh_suppressed_count
|
||
: typeof fields.feedback_refresh_suppressed_count === "number"
|
||
? fields.feedback_refresh_suppressed_count
|
||
: 0;
|
||
const parts = [`rh ${applied}/${routes}`];
|
||
if (replacements > 0) {
|
||
parts.push(`repl${replacements}`);
|
||
}
|
||
if (drift > 0) {
|
||
parts.push(`drift${drift}`);
|
||
}
|
||
if (feedbackAttempts > 0 || feedbackSuppressed > 0) {
|
||
parts.push(`fb${feedbackSuccesses}/${feedbackAttempts}`);
|
||
}
|
||
if (feedbackSuppressed > 0) {
|
||
parts.push(`sup${feedbackSuppressed}`);
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function meshConnectionManagerSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_peer_connection_manager_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
if (fields.enabled === false) {
|
||
return "manager off";
|
||
}
|
||
const attempted = typeof fields.attempted === "number" ? fields.attempted : 0;
|
||
const succeeded = typeof fields.succeeded === "number" ? fields.succeeded : 0;
|
||
const deferred = typeof fields.deferred === "number" ? fields.deferred : 0;
|
||
const relayControl = typeof fields.relay_control_count === "number" ? fields.relay_control_count : 0;
|
||
const base = relayControl > 0 ? `mgr ${succeeded}/${attempted} relay${relayControl}` : `mgr ${succeeded}/${attempted}`;
|
||
return deferred > 0 ? `${base} def${deferred}` : base;
|
||
}
|
||
|
||
function clusterCountLabel(count: number, language: Language) {
|
||
if (language === "en") {
|
||
return count === 1 ? "1 cluster" : `${count} clusters`;
|
||
}
|
||
const mod10 = count % 10;
|
||
const mod100 = count % 100;
|
||
if (mod10 === 1 && mod100 !== 11) {
|
||
return `${count} кластер`;
|
||
}
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||
return `${count} кластера`;
|
||
}
|
||
return `${count} кластеров`;
|
||
}
|
||
|
||
function linkTone(link: MeshLink) {
|
||
if (link.link_status !== "reachable") {
|
||
return "bad";
|
||
}
|
||
if (link.quality_score != null && link.quality_score < 70) {
|
||
return "weak";
|
||
}
|
||
if (link.latency_ms != null && link.latency_ms > 80) {
|
||
return "weak";
|
||
}
|
||
return "good";
|
||
}
|
||
|
||
function trimNodeName(value: string) {
|
||
return value.length > 16 ? `${value.slice(0, 14)}…` : value;
|
||
}
|
||
|
||
function confirmHighRisk(action: string) {
|
||
return window.confirm(`${action}?\n\nЭто высокорисковая операция владельца платформы. Действие будет записано в аудит.`);
|
||
}
|
||
|
||
function shortId(value?: string | null) {
|
||
if (!value) {
|
||
return "нет";
|
||
}
|
||
return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value;
|
||
}
|
||
|
||
function formatDate(value?: string | null) {
|
||
if (!value) {
|
||
return "никогда";
|
||
}
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
}).format(new Date(value));
|
||
}
|
||
|
||
function formatBytes(value?: number | null) {
|
||
if (value == null || Number.isNaN(value)) {
|
||
return "н/д";
|
||
}
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let size = value;
|
||
let index = 0;
|
||
while (size >= 1024 && index < units.length - 1) {
|
||
size /= 1024;
|
||
index += 1;
|
||
}
|
||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||
}
|
||
|
||
function statusLabel(value: string) {
|
||
const labels: Record<string, string> = {
|
||
active: "активно",
|
||
approved: "одобрено",
|
||
authoritative: "authoritative",
|
||
connecting: "подключается",
|
||
critical: "критично",
|
||
degraded: "degraded",
|
||
disabled: "выключено",
|
||
enabled: "включено",
|
||
failed: "ошибка",
|
||
healthy: "здоров",
|
||
isolated: "изолирован",
|
||
pending: "ожидает",
|
||
platform_managed: "платформенный",
|
||
rejected: "отклонено",
|
||
revoked: "отозвано",
|
||
running: "работает",
|
||
customer_managed: "клиентский",
|
||
stopped: "остановлено",
|
||
unknown: "неизвестно",
|
||
};
|
||
return labels[value] || value;
|
||
}
|