9840 lines
495 KiB
TypeScript
9840 lines
495 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||
import { AdminApiClient } from "./api/client";
|
||
import type {
|
||
AuditEvent,
|
||
AuditSummary,
|
||
AuthResult,
|
||
Cluster,
|
||
ClusterAdminSummary,
|
||
ClusterAuthorityState,
|
||
ClusterNode,
|
||
ClusterNodeGroup,
|
||
CreatedJoinToken,
|
||
FabricEntryPoint,
|
||
FabricEntryPointNode,
|
||
FabricEgressPool,
|
||
FabricEgressPoolNode,
|
||
FabricServiceChannelAccessTelemetry,
|
||
FabricServiceChannelBreadcrumbWindowPolicy,
|
||
FabricServiceChannelRecoveryPolicy,
|
||
FabricServiceChannelLeaseMaintenance,
|
||
FabricServiceChannelReadiness,
|
||
FabricServiceChannelRouteFeedbackObservation,
|
||
FabricServiceChannelRouteRebuildAlertSilence,
|
||
FabricServiceChannelRouteRebuildAttempt,
|
||
FabricServiceChannelRouteRebuildFeedbackHealthBreakdown,
|
||
FabricServiceChannelRouteRebuildHealthSummary,
|
||
FabricServiceChannelRouteRebuildIncident,
|
||
FabricServiceChannelRebuildSnapshotMaintenanceHealth,
|
||
FabricServiceChannelRebuildSnapshotWarmup,
|
||
FabricServiceChannelSchemaStatus,
|
||
FabricTestingFlag,
|
||
InstallationStatus,
|
||
JoinRequest,
|
||
MeshLink,
|
||
MeshRouteIntent,
|
||
NodeHeartbeat,
|
||
NodeJoinToken,
|
||
NodeSyntheticMeshConfig,
|
||
NodeTelemetryObservation,
|
||
NodeUpdatePlan,
|
||
NodeUpdateStatus,
|
||
NodeWorkloadDesiredState,
|
||
Organization,
|
||
OrganizationAdminSummary,
|
||
OrganizationMembership,
|
||
QoSPolicy,
|
||
ReleaseVersion,
|
||
Resource,
|
||
RoleAssignment,
|
||
UserAccount,
|
||
VPNClientDiagnosticCommand,
|
||
VPNClientDiagnosticStatus,
|
||
VPNConnection,
|
||
VPNConnectionLease,
|
||
VPNPacketStats,
|
||
WorkloadStatus,
|
||
} from "./types";
|
||
|
||
const storageKeys = {
|
||
baseUrl: "rap.webAdmin.baseUrl",
|
||
actorUserId: "rap.webAdmin.actorUserId",
|
||
auth: "rap.webAdmin.auth",
|
||
language: "rap.webAdmin.language",
|
||
vpnDiagnosticDeviceId: "rap.webAdmin.vpnDiagnosticDeviceId",
|
||
};
|
||
|
||
const defaultBaseUrl = "/api/v1";
|
||
const legacyDirectBackendPrefix = "http://192.168.200.61:8080/api/v1";
|
||
|
||
type FabricRebuildLedgerFilters = {
|
||
reporterNodeId: string;
|
||
routeId: string;
|
||
serviceClass: string;
|
||
generation: string;
|
||
feedbackSource: string;
|
||
feedbackChannelId: string;
|
||
feedbackViolationStatus: string;
|
||
offset: number;
|
||
};
|
||
|
||
const defaultFabricRebuildLedgerFilters: FabricRebuildLedgerFilters = {
|
||
reporterNodeId: "",
|
||
routeId: "",
|
||
serviceClass: "",
|
||
generation: "",
|
||
feedbackSource: "",
|
||
feedbackChannelId: "",
|
||
feedbackViolationStatus: "",
|
||
offset: 0,
|
||
};
|
||
|
||
const roleOptions = [
|
||
"entry-node",
|
||
"relay-node",
|
||
"core-mesh",
|
||
"rdp-worker",
|
||
"vnc-worker",
|
||
"vpn-exit",
|
||
"vpn-connector",
|
||
"file-storage-cache",
|
||
"update-cache",
|
||
"video-relay",
|
||
];
|
||
|
||
const roleDisplayNames: Record<string, string> = {
|
||
"entry-node": "Entry node",
|
||
"relay-node": "Relay node",
|
||
"core-mesh": "Mesh core",
|
||
"rdp-worker": "RDP worker",
|
||
"vnc-worker": "VNC worker",
|
||
"vpn-exit": "VPN exit",
|
||
"vpn-connector": "VPN connector",
|
||
"file-storage-cache": "File/cache storage",
|
||
"update-cache": "Update cache",
|
||
"video-relay": "Video relay",
|
||
};
|
||
|
||
const capabilityKeysByRole: Record<string, string[]> = {
|
||
"entry-node": ["can_accept_client_ingress"],
|
||
"relay-node": ["mesh_rendezvous_relay_control_contract", "mesh_peer_connection_manager"],
|
||
"core-mesh": ["native_node_agent", "mesh_peer_connection_manager", "mesh_listener_diagnostics"],
|
||
"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: "New Node" },
|
||
{ 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: "servers", ru: "Серверы", en: "Servers" },
|
||
{ id: "org-safe", ru: "Организации", en: "Organizations" },
|
||
{ id: "audit", ru: "Аудит", en: "Audit" },
|
||
] as const;
|
||
|
||
type ViewId = (typeof views)[number]["id"];
|
||
type Language = "ru" | "en";
|
||
type ConsoleMode = "admin" | "user";
|
||
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;
|
||
};
|
||
|
||
function normalizeStoredSession(input: unknown): WebAdminSession | null {
|
||
if (!input || typeof input !== "object") {
|
||
return null;
|
||
}
|
||
const session = input as WebAdminSession;
|
||
if (
|
||
typeof session.userId !== "string" ||
|
||
typeof session.email !== "string" ||
|
||
typeof session.authSessionId !== "string" ||
|
||
typeof session.accessToken !== "string" ||
|
||
typeof session.refreshToken !== "string" ||
|
||
typeof session.accessTokenExpiresAt !== "string" ||
|
||
typeof session.refreshTokenExpiresAt !== "string"
|
||
) {
|
||
return null;
|
||
}
|
||
if (!session.userId || !session.refreshToken) {
|
||
return null;
|
||
}
|
||
return {
|
||
userId: session.userId,
|
||
email: session.email,
|
||
authSessionId: session.authSessionId,
|
||
accessToken: session.accessToken,
|
||
refreshToken: session.refreshToken,
|
||
accessTokenExpiresAt: session.accessTokenExpiresAt,
|
||
refreshTokenExpiresAt: session.refreshTokenExpiresAt,
|
||
};
|
||
}
|
||
|
||
function isTokenExpired(expiresAt: string): boolean {
|
||
const expiresAtMs = Date.parse(expiresAt);
|
||
return !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now();
|
||
}
|
||
|
||
function loadStoredSession(): WebAdminSession | null {
|
||
try {
|
||
const stored = localStorage.getItem(storageKeys.auth);
|
||
if (!stored) {
|
||
return null;
|
||
}
|
||
const parsed = normalizeStoredSession(JSON.parse(stored));
|
||
if (!parsed || isTokenExpired(parsed.refreshTokenExpiresAt)) {
|
||
return null;
|
||
}
|
||
return parsed;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type JoinTokenFormState = {
|
||
ttlHours: number;
|
||
maxUses: number;
|
||
roles: string[];
|
||
nodeName: string;
|
||
nodeGroupId: string;
|
||
ownershipType: string;
|
||
purpose: string;
|
||
installMode: string;
|
||
dockerImage: string;
|
||
dockerContainerName: string;
|
||
dockerNetwork: string;
|
||
windowsStartupMode: string;
|
||
windowsInstallDir: string;
|
||
windowsNodeAgentSHA256: string;
|
||
linuxInstallDir: string;
|
||
linuxNodeAgentSHA256: string;
|
||
meshListenAddr: string;
|
||
meshListenPortMode: string;
|
||
meshListenAutoPortStart: number;
|
||
meshListenAutoPortEnd: number;
|
||
meshAdvertiseEndpoint: string;
|
||
meshAdvertiseTransport: string;
|
||
meshConnectivityMode: string;
|
||
meshNATType: string;
|
||
meshRegion: string;
|
||
controlPlaneEndpoint: string;
|
||
artifactEndpoints: string;
|
||
dockerImageArtifactSHA256: string;
|
||
pullImage: boolean;
|
||
replace: boolean;
|
||
syntheticRuntime: boolean;
|
||
};
|
||
|
||
const defaultJoinTokenForm: JoinTokenFormState = {
|
||
ttlHours: 24,
|
||
maxUses: 1,
|
||
roles: ["core-mesh"],
|
||
nodeName: "",
|
||
nodeGroupId: "",
|
||
ownershipType: "platform_managed",
|
||
purpose: "",
|
||
installMode: "docker",
|
||
dockerImage: "rap-node-agent:dev-enrollment-bootstrap-smoke",
|
||
dockerContainerName: "",
|
||
dockerNetwork: "host",
|
||
windowsStartupMode: "auto",
|
||
windowsInstallDir: "",
|
||
windowsNodeAgentSHA256: "",
|
||
linuxInstallDir: "",
|
||
linuxNodeAgentSHA256: "",
|
||
meshListenAddr: ":19131",
|
||
meshListenPortMode: "auto",
|
||
meshListenAutoPortStart: 19131,
|
||
meshListenAutoPortEnd: 19231,
|
||
meshAdvertiseEndpoint: "",
|
||
meshAdvertiseTransport: "direct_http",
|
||
meshConnectivityMode: "private_lan",
|
||
meshNATType: "none",
|
||
meshRegion: "docker-test",
|
||
controlPlaneEndpoint: "",
|
||
artifactEndpoints: "",
|
||
dockerImageArtifactSHA256: "",
|
||
pullImage: false,
|
||
replace: true,
|
||
syntheticRuntime: true,
|
||
};
|
||
|
||
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: "Устройство",
|
||
rememberMe: "Запомнить меня",
|
||
trustDevice: "Доверять этому устройству",
|
||
signIn: "Войти",
|
||
signingIn: "Вход...",
|
||
logout: "Выйти",
|
||
profile: "Профиль",
|
||
refresh: "Обновить",
|
||
refreshing: "Обновление...",
|
||
autoRefresh: "Автообновление",
|
||
lastRefresh: "Данные обновлены",
|
||
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: "Доступ к этой панели запрещен.",
|
||
sessionMode: "Режим сессии",
|
||
sessionModeAdmin: "Админ",
|
||
sessionModeUser: "Пользователь",
|
||
sessionRefreshedAt: "Сессия обновлена",
|
||
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: "Создать новый Docker-узел",
|
||
joinTokenText:
|
||
"Сначала создается одноразовый install token и Docker install profile. Затем команда запускается на Docker-хосте, агент отправляет заявку, а владелец платформы подтверждает ее.",
|
||
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",
|
||
rememberMe: "Remember me",
|
||
trustDevice: "Trust this device",
|
||
signIn: "Sign in",
|
||
signingIn: "Signing in...",
|
||
logout: "Logout",
|
||
profile: "Profile",
|
||
refresh: "Refresh",
|
||
refreshing: "Refreshing...",
|
||
autoRefresh: "Auto refresh",
|
||
lastRefresh: "Data refreshed",
|
||
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.",
|
||
sessionMode: "Session mode",
|
||
sessionModeAdmin: "Admin",
|
||
sessionModeUser: "User",
|
||
sessionRefreshedAt: "Session refreshed",
|
||
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 new Docker node",
|
||
joinTokenText:
|
||
"First create a one-time install token and Docker install profile. Then run the generated command on the Docker host; 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,
|
||
};
|
||
}
|
||
|
||
async function resolveConsoleMode(clientByUser: AdminApiClient): Promise<ConsoleMode | null> {
|
||
try {
|
||
await clientByUser.listClusterSummaries();
|
||
return "admin";
|
||
} catch {
|
||
try {
|
||
await Promise.all([clientByUser.listOrganizations(), clientByUser.listResources()]);
|
||
return "user";
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function App() {
|
||
const [authRestorationAttempted, setAuthRestorationAttempted] = useState(false);
|
||
const [rememberSession, setRememberSession] = useState(() => !!loadStoredSession());
|
||
const [baseUrl] = useState(() => {
|
||
const stored = localStorage.getItem(storageKeys.baseUrl)?.trim();
|
||
return !stored || stored.startsWith(legacyDirectBackendPrefix) ? defaultBaseUrl : stored;
|
||
});
|
||
const [session, setSession] = useState<WebAdminSession | null>(() => loadStoredSession());
|
||
const [consoleMode, setConsoleMode] = useState<ConsoleMode | null>(null);
|
||
const [sessionRefreshedAt, setSessionRefreshedAt] = useState("");
|
||
const [language, setLanguage] = useState<Language>(() => (localStorage.getItem(storageKeys.language) === "en" ? "en" : "ru"));
|
||
const [actorUserId, setActorUserId] = useState(session?.userId ?? localStorage.getItem(storageKeys.actorUserId) ?? "");
|
||
const [loginForm, setLoginForm] = useState({
|
||
email: "",
|
||
password: "",
|
||
deviceLabel: "Панель владельца платформы",
|
||
trustDevice: true,
|
||
rememberMe: true,
|
||
showPassword: false,
|
||
});
|
||
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 [joinTokens, setJoinTokens] = useState<NodeJoinToken[]>([]);
|
||
const [releaseVersions, setReleaseVersions] = useState<ReleaseVersion[]>([]);
|
||
const [nodeUpdatePlansByNode, setNodeUpdatePlansByNode] = useState<Record<string, NodeUpdatePlan>>({});
|
||
const [nodeUpdateStatusesByNode, setNodeUpdateStatusesByNode] = useState<Record<string, NodeUpdateStatus[]>>({});
|
||
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 [routeIntents, setRouteIntents] = useState<MeshRouteIntent[]>([]);
|
||
const [syntheticMeshConfigsByNode, setSyntheticMeshConfigsByNode] = useState<Record<string, NodeSyntheticMeshConfig>>({});
|
||
const [fabricRouteFeedback, setFabricRouteFeedback] = useState<FabricServiceChannelRouteFeedbackObservation[]>([]);
|
||
const [fabricRebuildAttempts, setFabricRebuildAttempts] = useState<FabricServiceChannelRouteRebuildAttempt[]>([]);
|
||
const [fabricRebuildHealth, setFabricRebuildHealth] = useState<FabricServiceChannelRouteRebuildHealthSummary | null>(null);
|
||
const [fabricRebuildSilences, setFabricRebuildSilences] = useState<FabricServiceChannelRouteRebuildAlertSilence[]>([]);
|
||
const [fabricReadiness, setFabricReadiness] = useState<FabricServiceChannelReadiness | null>(null);
|
||
const [fabricSchemaStatus, setFabricSchemaStatus] = useState<FabricServiceChannelSchemaStatus | null>(null);
|
||
const [fabricSnapshotHealth, setFabricSnapshotHealth] = useState<FabricServiceChannelRebuildSnapshotMaintenanceHealth | null>(null);
|
||
const [fabricSnapshotWarmup, setFabricSnapshotWarmup] = useState<FabricServiceChannelRebuildSnapshotWarmup | null>(null);
|
||
const [fabricLeaseMaintenance, setFabricLeaseMaintenance] = useState<FabricServiceChannelLeaseMaintenance | null>(null);
|
||
const [fabricAccessTelemetry, setFabricAccessTelemetry] = useState<FabricServiceChannelAccessTelemetry | null>(null);
|
||
const [fabricRebuildIncidents, setFabricRebuildIncidents] = useState<FabricServiceChannelRouteRebuildIncident[]>([]);
|
||
const [fabricRebuildLedgerDeep, setFabricRebuildLedgerDeep] = useState(false);
|
||
const [fabricRebuildLedgerFilters, setFabricRebuildLedgerFilters] = useState<FabricRebuildLedgerFilters>(defaultFabricRebuildLedgerFilters);
|
||
const [fabricRecoveryPolicy, setFabricRecoveryPolicy] = useState<FabricServiceChannelRecoveryPolicy | null>(null);
|
||
const [fabricBreadcrumbWindowPolicy, setFabricBreadcrumbWindowPolicy] = useState<FabricServiceChannelBreadcrumbWindowPolicy | null>(null);
|
||
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 [vpnPacketStats, setVPNPacketStats] = useState<Record<string, VPNPacketStats>>({});
|
||
const [vpnDiagnosticDeviceId, setVPNDiagnosticDeviceId] = useState(() => localStorage.getItem(storageKeys.vpnDiagnosticDeviceId) || "");
|
||
const [vpnClientDiagnostics, setVPNClientDiagnostics] = useState<VPNClientDiagnosticStatus[]>([]);
|
||
const [vpnClientDiagnostic, setVPNClientDiagnostic] = useState<VPNClientDiagnosticStatus | null>(null);
|
||
const [vpnDiagnosticTestUrl, setVPNDiagnosticTestUrl] = useState("http://2ip.ru/");
|
||
const [lastVPNDiagnosticCommand, setLastVPNDiagnosticCommand] = useState<VPNClientDiagnosticCommand | null>(null);
|
||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||
const [users, setUsers] = useState<UserAccount[]>([]);
|
||
const [resources, setResources] = useState<Resource[]>([]);
|
||
const [membershipsByOrg, setMembershipsByOrg] = useState<Record<string, OrganizationMembership[]>>({});
|
||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||
const [fabricDrilldownAudit, setFabricDrilldownAudit] = useState<AuditEvent[]>([]);
|
||
const [fabricDrilldownAuditSummary, setFabricDrilldownAuditSummary] = useState<AuditSummary | null>(null);
|
||
const [lastDataRefreshAt, setLastDataRefreshAt] = useState("");
|
||
const [liveTransport, setLiveTransport] = useState<"sse" | "poll">("poll");
|
||
|
||
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 [fabricRecoveryPolicyForm, setFabricRecoveryPolicyForm] = useState({
|
||
hysteresisPenalty: "150",
|
||
promotionMinSamples: "64",
|
||
demotionFailureThreshold: "1",
|
||
demotionDropThreshold: "1",
|
||
demotionSlowThreshold: "1",
|
||
demotionRebuildEnabled: true,
|
||
demotionFencedEnabled: true,
|
||
});
|
||
const [fabricBreadcrumbWindowPolicyForm, setFabricBreadcrumbWindowPolicyForm] = useState({
|
||
currentWindowSeconds: "1800",
|
||
historyWindowSeconds: "86400",
|
||
});
|
||
const [joinTokenForm, setJoinTokenForm] = useState<JoinTokenFormState>(defaultJoinTokenForm);
|
||
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 [meshListenerDrafts, setMeshListenerDrafts] = useState<
|
||
Record<string, { listenAddr: string; mode: string; autoRange: string; advertiseEndpoint: string; advertiseTransport: string; connectivity: string; nat: string; region: 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 [organizationForm, setOrganizationForm] = useState({ slug: "", name: "" });
|
||
const [userForm, setUserForm] = useState({ email: "", password: "", platformRole: "user" });
|
||
const [membershipForm, setMembershipForm] = useState({ organizationId: "", userId: "", roleId: "org_member" });
|
||
const [resourceSecretDialog, setResourceSecretDialog] = useState<Resource | null>(null);
|
||
const [resourceSecretForm, setResourceSecretForm] = useState({ username: "", password: "", domain: "" });
|
||
const [resourceForm, setResourceForm] = useState({
|
||
organizationId: "",
|
||
name: "",
|
||
address: "",
|
||
protocol: "rdp",
|
||
routeMode: "vpn_exit",
|
||
entryNode: "",
|
||
exitNode: "",
|
||
tags: "",
|
||
username: "",
|
||
password: "",
|
||
domain: "",
|
||
});
|
||
const [androidClientVersion, setAndroidClientVersion] = useState("");
|
||
const [androidClientPublishedAt, setAndroidClientPublishedAt] = useState("");
|
||
const [androidClientVersionedPath, setAndroidClientVersionedPath] = useState("");
|
||
const androidClientDefaultLatestFilename = "rap-android-rdp-vpn-latest-release.apk";
|
||
const [androidClientLatestPath, setAndroidClientLatestPath] = useState(androidClientDefaultLatestFilename);
|
||
|
||
const client = useMemo(() => new AdminApiClient({ baseUrl, actorUserId }), [baseUrl, actorUserId]);
|
||
const authClient = useMemo(() => new AdminApiClient({ baseUrl, actorUserId: "" }), [baseUrl]);
|
||
const clusterScopeRequestSeq = useRef(0);
|
||
const autoRefreshInFlight = useRef(false);
|
||
const t = copy[language];
|
||
const selectedCluster = clusters.find((cluster) => cluster.id === selectedClusterId) || null;
|
||
const selectedSummary = clusterSummaries.find((summary) => summary.cluster_id === selectedClusterId) || null;
|
||
const portalDownloadBaseUrl = useMemo(() => downloadBaseUrl(baseUrl), [baseUrl]);
|
||
const resolvePortalDownloadPath = useCallback((artifactPath: string | undefined, fallbackName: string) => {
|
||
if (!artifactPath) {
|
||
return fallbackName;
|
||
}
|
||
const normalized = artifactPath.trim();
|
||
if (!normalized) {
|
||
return fallbackName;
|
||
}
|
||
if (/^https?:\/\//i.test(normalized) || normalized.startsWith("/")) {
|
||
if (normalized.startsWith("/")) {
|
||
return normalized.substring(1);
|
||
}
|
||
return normalized;
|
||
}
|
||
if (normalized.startsWith("downloads/")) {
|
||
return normalized;
|
||
}
|
||
return `downloads/${normalized.replace(/^\.\/+/, "").replace(/^\/+/, "")}`;
|
||
}, []);
|
||
|
||
const portalAndroidLatestName = resolvePortalDownloadPath(androidClientLatestPath, androidClientDefaultLatestFilename);
|
||
const portalAndroidVersionedName = androidClientVersionedPath
|
||
? resolvePortalDownloadPath(androidClientVersionedPath, portalAndroidLatestName)
|
||
: portalAndroidLatestName;
|
||
const portalAndroidArtifactPath = androidClientVersionedPath ? portalAndroidVersionedName : portalAndroidLatestName;
|
||
const portalAndroidDownloadHref = /^https?:\/\//i.test(portalAndroidArtifactPath)
|
||
? portalAndroidArtifactPath
|
||
: `${portalDownloadBaseUrl}/${portalAndroidArtifactPath}`;
|
||
const androidVPNDownloadUrl = `${portalAndroidDownloadHref}${
|
||
androidClientPublishedAt ? `?_v=${encodeURIComponent(androidClientPublishedAt)}` : ""
|
||
}`;
|
||
const joinTokenScope = useMemo(() => buildJoinTokenScope(joinTokenForm), [joinTokenForm]);
|
||
const lastJoinTokenInstallForm = useMemo(
|
||
() => (lastJoinToken ? joinTokenFormFromScope(lastJoinToken.scope, joinTokenForm) : joinTokenForm),
|
||
[lastJoinToken, 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 persistSession = useCallback((nextSession: WebAdminSession | null, remember = false) => {
|
||
if (nextSession && remember) {
|
||
localStorage.setItem(storageKeys.auth, JSON.stringify(nextSession));
|
||
localStorage.setItem(storageKeys.actorUserId, nextSession.userId);
|
||
setRememberSession(true);
|
||
return;
|
||
}
|
||
setRememberSession(false);
|
||
localStorage.removeItem(storageKeys.auth);
|
||
localStorage.removeItem(storageKeys.actorUserId);
|
||
}, []);
|
||
|
||
const updateAndroidPortalMetadata = useCallback(async () => {
|
||
try {
|
||
const manifestUrl = `${portalDownloadBaseUrl}/downloads/rap-android-rdp-vpn-build.json?_cb=${Date.now()}`;
|
||
const response = await fetch(manifestUrl, { cache: "no-store" });
|
||
if (!response.ok) {
|
||
setAndroidClientVersion("");
|
||
setAndroidClientPublishedAt(new Date().toISOString());
|
||
setAndroidClientVersionedPath("");
|
||
setAndroidClientLatestPath(androidClientDefaultLatestFilename);
|
||
return;
|
||
}
|
||
const payload = (await response.json()) as {
|
||
version?: { name?: string };
|
||
published?: { timestamp_utc?: string; path?: string };
|
||
release_paths?: {
|
||
latest?: string;
|
||
versioned?: string;
|
||
};
|
||
};
|
||
setAndroidClientVersion(payload.version?.name || "");
|
||
setAndroidClientPublishedAt(payload.published?.timestamp_utc || "");
|
||
setAndroidClientVersionedPath(payload.release_paths?.versioned || "");
|
||
setAndroidClientLatestPath(payload.published?.path || payload.release_paths?.latest || androidClientDefaultLatestFilename);
|
||
} catch {
|
||
setAndroidClientVersion("");
|
||
setAndroidClientPublishedAt(new Date().toISOString());
|
||
setAndroidClientVersionedPath("");
|
||
setAndroidClientLatestPath(androidClientDefaultLatestFilename);
|
||
}
|
||
}, [portalDownloadBaseUrl]);
|
||
|
||
const visibleNodeTreeRows = useMemo(
|
||
() => buildNodeInventoryTreeRows(visibleNodeInventory, nodeGroups, selectedClusterId, t, new Set(collapsedNodeGroupKeys)),
|
||
[collapsedNodeGroupKeys, nodeGroups, selectedClusterId, t, visibleNodeInventory],
|
||
);
|
||
const fabricDrilldownAuditEvents = useMemo(
|
||
() => fabricDrilldownAudit.slice(0, 8),
|
||
[fabricDrilldownAudit],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (authRestorationAttempted) {
|
||
return;
|
||
}
|
||
setAuthRestorationAttempted(true);
|
||
|
||
const storedSession = loadStoredSession();
|
||
if (!storedSession) {
|
||
return;
|
||
}
|
||
|
||
if (isTokenExpired(storedSession.refreshTokenExpiresAt)) {
|
||
localStorage.removeItem(storageKeys.auth);
|
||
localStorage.removeItem(storageKeys.actorUserId);
|
||
setRememberSession(false);
|
||
return;
|
||
}
|
||
|
||
const attemptRestore = async () => {
|
||
try {
|
||
const result = await authClient.refresh({
|
||
refreshToken: storedSession.refreshToken,
|
||
});
|
||
const restored = normalizeAuthResult(result);
|
||
if (!restored.userId || !restored.authSessionId) {
|
||
throw new Error("Не удалось восстановить сессию.");
|
||
}
|
||
|
||
const accessClient = new AdminApiClient({ baseUrl, actorUserId: restored.userId });
|
||
const mode = await resolveConsoleMode(accessClient);
|
||
if (!mode) {
|
||
throw new Error("Доступ к этой панели запрещен.");
|
||
}
|
||
setActorUserId(restored.userId);
|
||
persistSession(restored, true);
|
||
setSession(restored);
|
||
setSessionRefreshedAt(new Date().toISOString());
|
||
setLoginForm((previous) => ({ ...previous, email: restored.email }));
|
||
setConsoleMode(mode);
|
||
} catch {
|
||
localStorage.removeItem(storageKeys.auth);
|
||
localStorage.removeItem(storageKeys.actorUserId);
|
||
setRememberSession(false);
|
||
setSessionRefreshedAt("");
|
||
setSession(null);
|
||
setActorUserId("");
|
||
setConsoleMode(null);
|
||
}
|
||
};
|
||
|
||
void attemptRestore();
|
||
}, [authClient, authRestorationAttempted, baseUrl, persistSession]);
|
||
|
||
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);
|
||
}
|
||
if (!session || !rememberSession) {
|
||
localStorage.removeItem(storageKeys.auth);
|
||
localStorage.removeItem(storageKeys.actorUserId);
|
||
}
|
||
}, [baseUrl, language, rememberSession, 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]);
|
||
|
||
useEffect(() => {
|
||
if (!session || consoleMode !== "admin" || !selectedClusterId) {
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const tick = () => {
|
||
if (cancelled || loading || autoRefreshInFlight.current || document.visibilityState === "hidden") {
|
||
return;
|
||
}
|
||
autoRefreshInFlight.current = true;
|
||
refreshLiveData(selectedClusterId)
|
||
.catch((err) => {
|
||
if (!cancelled) {
|
||
setError(err instanceof Error ? err.message : "Не удалось автообновить данные панели.");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
autoRefreshInFlight.current = false;
|
||
});
|
||
};
|
||
let eventSource: EventSource | null = null;
|
||
if (typeof window.EventSource === "function") {
|
||
eventSource = new EventSource(client.clusterEventsURL(selectedClusterId));
|
||
eventSource.onopen = () => {
|
||
if (!cancelled) {
|
||
setLiveTransport("sse");
|
||
}
|
||
};
|
||
eventSource.onerror = () => {
|
||
if (!cancelled) {
|
||
setLiveTransport("poll");
|
||
}
|
||
};
|
||
eventSource.addEventListener("cluster.changed", tick);
|
||
}
|
||
const interval = window.setInterval(tick, eventSource ? 30_000 : 10_000);
|
||
return () => {
|
||
cancelled = true;
|
||
eventSource?.close();
|
||
window.clearInterval(interval);
|
||
};
|
||
}, [client, consoleMode, loading, selectedClusterId, session?.userId]);
|
||
|
||
async function refreshAll(preferredClusterId = selectedClusterId) {
|
||
if (!actorUserId.trim()) {
|
||
setError(t.noLoginError);
|
||
return;
|
||
}
|
||
if (consoleMode === "user") {
|
||
await refreshUserPortal();
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
setNotice("");
|
||
try {
|
||
const [loadedClusters, loadedSummaries, loadedOrganizations, loadedUsers, loadedResources] = await Promise.all([
|
||
client.listClusters(),
|
||
client.listClusterSummaries(),
|
||
client.listOrganizations(),
|
||
client.listUsers(),
|
||
client.listResources(),
|
||
]);
|
||
setClusters(loadedClusters);
|
||
setClusterSummaries(loadedSummaries);
|
||
setOrganizations(loadedOrganizations);
|
||
setUsers(loadedUsers);
|
||
setResources(loadedResources);
|
||
if (!organizationId && loadedOrganizations[0]?.id) {
|
||
setOrganizationId(loadedOrganizations[0].id);
|
||
}
|
||
setMembershipForm((previous) => ({ ...previous, organizationId: previous.organizationId || loadedOrganizations[0]?.id || "" }));
|
||
setResourceForm((previous) => ({ ...previous, organizationId: previous.organizationId || loadedOrganizations[0]?.id || "" }));
|
||
const membershipEntries = await Promise.all(
|
||
loadedOrganizations.map(async (org) => [org.id, await client.listOrganizationMemberships(org.id)] as const),
|
||
);
|
||
setMembershipsByOrg(Object.fromEntries(membershipEntries));
|
||
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);
|
||
}
|
||
setLastDataRefreshAt(new Date().toISOString());
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Неизвестная ошибка панели управления платформой.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function refreshUserPortal() {
|
||
if (!actorUserId.trim()) {
|
||
setError("Войдите, чтобы загрузить личный кабинет.");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
setNotice("");
|
||
try {
|
||
await updateAndroidPortalMetadata();
|
||
const [loadedOrganizations, loadedResources] = await Promise.all([client.listOrganizations(), client.listResources()]);
|
||
setOrganizations(loadedOrganizations);
|
||
setResources(loadedResources);
|
||
if (!organizationId && loadedOrganizations[0]?.id) {
|
||
setOrganizationId(loadedOrganizations[0].id);
|
||
}
|
||
const membershipEntries = await Promise.all(
|
||
loadedOrganizations.map(async (org) => [org.id, await client.listOrganizationMemberships(org.id)] as const),
|
||
);
|
||
setMembershipsByOrg(Object.fromEntries(membershipEntries));
|
||
setLastDataRefreshAt(new Date().toISOString());
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Не удалось загрузить личный кабинет.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function refreshLiveData(clusterId: string) {
|
||
if (!actorUserId.trim()) {
|
||
return;
|
||
}
|
||
const [loadedSummaries, loadedNodes, loadedOrganizations, loadedUsers, loadedResources] = await Promise.all([
|
||
client.listClusterSummaries(),
|
||
client.listNodes(clusterId),
|
||
client.listOrganizations(),
|
||
client.listUsers(),
|
||
client.listResources(),
|
||
]);
|
||
setClusterSummaries(loadedSummaries);
|
||
setOrganizations(loadedOrganizations);
|
||
setUsers(loadedUsers);
|
||
setResources(loadedResources);
|
||
setAllNodesByCluster((previous) => ({ ...previous, [clusterId]: loadedNodes }));
|
||
await loadClusterScope(clusterId, { preserveEditableForms: true });
|
||
setLastDataRefreshAt(new Date().toISOString());
|
||
}
|
||
|
||
async function loadClusterScope(clusterId: string, options: { preserveEditableForms?: boolean } = {}) {
|
||
const requestSeq = ++clusterScopeRequestSeq.current;
|
||
const rebuildLedgerLimit = fabricRebuildLedgerDeep ? 20 : 10;
|
||
const rebuildLedgerOffset = fabricRebuildLedgerDeep ? fabricRebuildLedgerFilters.offset : 0;
|
||
const rebuildLedgerInput = {
|
||
reporterNodeId: fabricRebuildLedgerFilters.reporterNodeId || undefined,
|
||
routeId: fabricRebuildLedgerFilters.routeId || undefined,
|
||
serviceClass: fabricRebuildLedgerFilters.serviceClass || undefined,
|
||
generation: fabricRebuildLedgerFilters.generation || undefined,
|
||
feedbackSource: fabricRebuildLedgerFilters.feedbackSource || undefined,
|
||
feedbackChannelId: fabricRebuildLedgerFilters.feedbackChannelId || undefined,
|
||
feedbackViolationStatus: fabricRebuildLedgerFilters.feedbackViolationStatus || undefined,
|
||
limit: rebuildLedgerLimit,
|
||
offset: rebuildLedgerOffset,
|
||
enrichment: fabricRebuildLedgerDeep ? ("deep" as const) : ("summary" as const),
|
||
};
|
||
const [
|
||
loadedNodes,
|
||
loadedNodeGroups,
|
||
loadedJoinRequests,
|
||
loadedJoinTokens,
|
||
loadedReleaseVersions,
|
||
loadedAuthority,
|
||
loadedAudit,
|
||
loadedFabricDrilldownAuditResult,
|
||
loadedMeshLinks,
|
||
loadedRouteIntents,
|
||
loadedFabricRouteFeedback,
|
||
loadedFabricRebuildAttempts,
|
||
loadedFabricRebuildHealth,
|
||
loadedFabricRebuildSilences,
|
||
loadedFabricReadiness,
|
||
loadedFabricSchemaStatus,
|
||
loadedFabricSnapshotHealth,
|
||
loadedFabricLeaseMaintenance,
|
||
loadedFabricAccessTelemetry,
|
||
loadedFabricRebuildIncidents,
|
||
loadedFabricRecoveryPolicy,
|
||
loadedFabricBreadcrumbWindowPolicy,
|
||
loadedQosPolicies,
|
||
loadedEntryPoints,
|
||
loadedEgressPools,
|
||
loadedVPNConnections,
|
||
loadedTestingFlags,
|
||
] =
|
||
await Promise.all([
|
||
client.listNodes(clusterId),
|
||
client.listNodeGroups(clusterId),
|
||
client.listJoinRequests(clusterId),
|
||
client.listJoinTokens(clusterId),
|
||
client.listReleaseVersions(clusterId, "rap-node-agent", "dev"),
|
||
client.getClusterAuthority(clusterId),
|
||
client.listAudit(clusterId),
|
||
client.getFabricServiceChannelRebuildInvestigationBreadcrumbs(clusterId, { limit: 20 }),
|
||
client.listMeshLinks(clusterId),
|
||
client.listRouteIntents(clusterId),
|
||
client.listFabricServiceChannelRouteFeedback(clusterId, { includeExpired: true }),
|
||
client.listFabricServiceChannelRouteRebuildAttempts(clusterId, rebuildLedgerInput),
|
||
client.getFabricServiceChannelRouteRebuildHealthSummary(clusterId, { limit: 5 }),
|
||
client.listFabricServiceChannelRouteRebuildAlertSilences(clusterId),
|
||
client.getFabricServiceChannelReadiness(clusterId, { limit: 5 }),
|
||
client.getFabricServiceChannelSchemaStatus(clusterId),
|
||
client.getFabricServiceChannelRebuildSnapshotMaintenanceHealth(clusterId, { limit: 50, minAgeSeconds: 60, heartbeatThreshold: 2 }),
|
||
client.getFabricServiceChannelLeaseMaintenance(clusterId, { limit: 20, includeExpired: true }),
|
||
client.getFabricServiceChannelAccessTelemetry(clusterId, { limit: 20 }),
|
||
client.listFabricServiceChannelRouteRebuildIncidents(clusterId, { limit: 5 }),
|
||
client.getFabricServiceChannelRecoveryPolicy(clusterId),
|
||
client.getFabricServiceChannelBreadcrumbWindowPolicy(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);
|
||
setJoinTokens(loadedJoinTokens);
|
||
setReleaseVersions(loadedReleaseVersions);
|
||
setAuthority(loadedAuthority);
|
||
if (!options.preserveEditableForms) {
|
||
setAuthorityForm({
|
||
authorityState: loadedAuthority.authority_state,
|
||
mutationMode: loadedAuthority.mutation_mode,
|
||
notes: loadedAuthority.notes || "",
|
||
});
|
||
}
|
||
setAudit(loadedAudit);
|
||
setFabricDrilldownAudit(loadedFabricDrilldownAuditResult.events);
|
||
setFabricDrilldownAuditSummary(loadedFabricDrilldownAuditResult.summary || null);
|
||
setMeshLinks(loadedMeshLinks);
|
||
setRouteIntents(loadedRouteIntents);
|
||
setFabricRouteFeedback(loadedFabricRouteFeedback);
|
||
setFabricRebuildAttempts(loadedFabricRebuildAttempts);
|
||
setFabricRebuildHealth(loadedFabricRebuildHealth);
|
||
setFabricRebuildSilences(loadedFabricRebuildSilences);
|
||
setFabricReadiness(loadedFabricReadiness);
|
||
setFabricSchemaStatus(loadedFabricSchemaStatus);
|
||
setFabricSnapshotHealth(loadedFabricSnapshotHealth);
|
||
setFabricLeaseMaintenance(loadedFabricLeaseMaintenance);
|
||
setFabricAccessTelemetry(loadedFabricAccessTelemetry);
|
||
setFabricRebuildIncidents(loadedFabricRebuildIncidents);
|
||
setFabricRecoveryPolicy(loadedFabricRecoveryPolicy);
|
||
setFabricBreadcrumbWindowPolicy(loadedFabricBreadcrumbWindowPolicy);
|
||
if (!options.preserveEditableForms) {
|
||
setFabricBreadcrumbWindowPolicyForm({
|
||
currentWindowSeconds: String(loadedFabricBreadcrumbWindowPolicy.current_window_seconds || 1800),
|
||
historyWindowSeconds: String(loadedFabricBreadcrumbWindowPolicy.history_window_seconds || 86400),
|
||
});
|
||
}
|
||
setFabricRecoveryPolicyForm({
|
||
hysteresisPenalty: String(loadedFabricRecoveryPolicy.hysteresis_penalty),
|
||
promotionMinSamples: String(loadedFabricRecoveryPolicy.promotion_min_samples),
|
||
demotionFailureThreshold: String(loadedFabricRecoveryPolicy.demotion_failure_threshold),
|
||
demotionDropThreshold: String(loadedFabricRecoveryPolicy.demotion_drop_threshold),
|
||
demotionSlowThreshold: String(loadedFabricRecoveryPolicy.demotion_slow_threshold),
|
||
demotionRebuildEnabled: loadedFabricRecoveryPolicy.demotion_rebuild_enabled,
|
||
demotionFencedEnabled: loadedFabricRecoveryPolicy.demotion_fenced_enabled,
|
||
});
|
||
setQosPolicies(loadedQosPolicies);
|
||
setEntryPoints(loadedEntryPoints);
|
||
setEgressPools(loadedEgressPools);
|
||
setVPNConnections(loadedVPNConnections);
|
||
setTestingFlags(loadedTestingFlags);
|
||
const diagnostics = await client.listVPNClientDiagnosticStatuses(clusterId);
|
||
if (requestSeq !== clusterScopeRequestSeq.current) {
|
||
return;
|
||
}
|
||
setVPNClientDiagnostics(diagnostics);
|
||
const selectedDiagnostic =
|
||
diagnostics.find((item) => item.device_id === vpnDiagnosticDeviceId.trim()) ||
|
||
diagnostics[0] ||
|
||
null;
|
||
setVPNClientDiagnostic(selectedDiagnostic);
|
||
if (!vpnDiagnosticDeviceId.trim() && selectedDiagnostic) {
|
||
setVPNDiagnosticDeviceId(selectedDiagnostic.device_id);
|
||
localStorage.setItem(storageKeys.vpnDiagnosticDeviceId, selectedDiagnostic.device_id);
|
||
}
|
||
|
||
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 updatePlanEntries = await Promise.all(
|
||
loadedNodes.map(async (node) => [node.id, await client.getNodeUpdatePlan(clusterId, node.id, { currentVersion: node.reported_version })] as const),
|
||
);
|
||
if (requestSeq !== clusterScopeRequestSeq.current) {
|
||
return;
|
||
}
|
||
setNodeUpdatePlansByNode(Object.fromEntries(updatePlanEntries));
|
||
|
||
const updateStatusEntries = await Promise.all(
|
||
loadedNodes.map(async (node) => [node.id, await client.listNodeUpdateStatuses(clusterId, node.id, 80)] as const),
|
||
);
|
||
if (requestSeq !== clusterScopeRequestSeq.current) {
|
||
return;
|
||
}
|
||
setNodeUpdateStatusesByNode(Object.fromEntries(updateStatusEntries));
|
||
|
||
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));
|
||
|
||
const vpnPacketStatEntries = await Promise.all(
|
||
loadedVPNConnections.map(async (connection) => [connection.id, await client.getVPNPacketStats(clusterId, connection.id)] as const),
|
||
);
|
||
if (requestSeq !== clusterScopeRequestSeq.current) {
|
||
return;
|
||
}
|
||
setVPNPacketStats(Object.fromEntries(vpnPacketStatEntries));
|
||
}
|
||
|
||
async function loadFabricRebuildLedger(nextDeep = fabricRebuildLedgerDeep, nextFilters = fabricRebuildLedgerFilters) {
|
||
if (!selectedClusterId) {
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
setNotice("");
|
||
try {
|
||
const attempts = await client.listFabricServiceChannelRouteRebuildAttempts(selectedClusterId, {
|
||
reporterNodeId: nextFilters.reporterNodeId || undefined,
|
||
routeId: nextFilters.routeId || undefined,
|
||
serviceClass: nextFilters.serviceClass || undefined,
|
||
generation: nextFilters.generation || undefined,
|
||
feedbackSource: nextFilters.feedbackSource || undefined,
|
||
feedbackChannelId: nextFilters.feedbackChannelId || undefined,
|
||
feedbackViolationStatus: nextFilters.feedbackViolationStatus || undefined,
|
||
limit: nextDeep ? 20 : 10,
|
||
offset: nextDeep ? nextFilters.offset : 0,
|
||
enrichment: nextDeep ? "deep" : "summary",
|
||
});
|
||
setFabricRebuildLedgerDeep(nextDeep);
|
||
setFabricRebuildLedgerFilters(nextFilters);
|
||
setFabricRebuildAttempts(attempts);
|
||
setNotice(nextDeep ? "Deep rebuild ledger loaded." : "Fast rebuild ledger loaded.");
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Не удалось загрузить rebuild ledger.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function refreshFabricRebuildDiagnostics() {
|
||
if (!selectedClusterId) {
|
||
return;
|
||
}
|
||
const [health, silences, readiness, schemaStatus, snapshotHealth, leaseMaintenance, accessTelemetry, incidents, drilldownAudit, breadcrumbWindowPolicy] = await Promise.all([
|
||
client.getFabricServiceChannelRouteRebuildHealthSummary(selectedClusterId, { limit: 5 }),
|
||
client.listFabricServiceChannelRouteRebuildAlertSilences(selectedClusterId),
|
||
client.getFabricServiceChannelReadiness(selectedClusterId, { limit: 5 }),
|
||
client.getFabricServiceChannelSchemaStatus(selectedClusterId),
|
||
client.getFabricServiceChannelRebuildSnapshotMaintenanceHealth(selectedClusterId, { limit: 50, minAgeSeconds: 60, heartbeatThreshold: 2 }),
|
||
client.getFabricServiceChannelLeaseMaintenance(selectedClusterId, { limit: 20, includeExpired: true }),
|
||
client.getFabricServiceChannelAccessTelemetry(selectedClusterId, { limit: 20 }),
|
||
client.listFabricServiceChannelRouteRebuildIncidents(selectedClusterId, { limit: 5 }),
|
||
client.getFabricServiceChannelRebuildInvestigationBreadcrumbs(selectedClusterId, { limit: 20 }),
|
||
client.getFabricServiceChannelBreadcrumbWindowPolicy(selectedClusterId),
|
||
]);
|
||
setFabricRebuildHealth(health);
|
||
setFabricRebuildSilences(silences);
|
||
setFabricReadiness(readiness);
|
||
setFabricSchemaStatus(schemaStatus);
|
||
setFabricSnapshotHealth(snapshotHealth);
|
||
setFabricLeaseMaintenance(leaseMaintenance);
|
||
setFabricAccessTelemetry(accessTelemetry);
|
||
setFabricRebuildIncidents(incidents);
|
||
setFabricDrilldownAudit(drilldownAudit.events);
|
||
setFabricDrilldownAuditSummary(drilldownAudit.summary || null);
|
||
setFabricBreadcrumbWindowPolicy(breadcrumbWindowPolicy);
|
||
setFabricBreadcrumbWindowPolicyForm({
|
||
currentWindowSeconds: String(breadcrumbWindowPolicy.current_window_seconds || 1800),
|
||
historyWindowSeconds: String(breadcrumbWindowPolicy.history_window_seconds || 86400),
|
||
});
|
||
}
|
||
|
||
async function warmupFabricRebuildSnapshots() {
|
||
if (!selectedClusterId) {
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true);
|
||
const warmup = await client.warmupFabricServiceChannelRebuildSnapshots(selectedClusterId, { limit: 10, staleAfterSeconds: 60 });
|
||
setFabricSnapshotWarmup(warmup);
|
||
await refreshFabricRebuildDiagnostics();
|
||
setNotice(`Snapshot warmup: warmed ${warmup.warmed_count}, fresh ${warmup.already_fresh_count}, errors ${warmup.error_count}.`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Не удалось прогреть rebuild snapshots.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function cleanupFabricServiceChannelLeases() {
|
||
if (!selectedClusterId) {
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true);
|
||
const result = await client.cleanupFabricServiceChannelLeases(selectedClusterId, { limit: 100 });
|
||
setFabricLeaseMaintenance(result);
|
||
setNotice(`Service-channel lease cleanup: deleted ${result.deleted_expired_count || 0}, active ${result.active_count}, expired ${result.expired_count}.`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Не удалось очистить service-channel leases.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function openFabricRebuildIncidentDeepLedger(incident: FabricServiceChannelRouteRebuildIncident) {
|
||
const filters = {
|
||
reporterNodeId: incident.reporter_node_id,
|
||
routeId: incident.route_id,
|
||
serviceClass: incident.service_class,
|
||
generation: incident.generation || "",
|
||
feedbackSource: "",
|
||
feedbackChannelId: incident.channel_id || "",
|
||
feedbackViolationStatus: "",
|
||
offset: 0,
|
||
};
|
||
await client.recordFabricServiceChannelRouteRebuildInvestigation(selectedClusterId, {
|
||
reporterNodeId: incident.reporter_node_id,
|
||
routeId: incident.route_id,
|
||
serviceClass: incident.service_class,
|
||
generation: incident.generation || "",
|
||
guardStatus: incident.guard_status,
|
||
incidentId: incident.fingerprint,
|
||
});
|
||
const drilldownAudit = await client.getFabricServiceChannelRebuildInvestigationBreadcrumbs(selectedClusterId, { limit: 20 });
|
||
setFabricDrilldownAudit(drilldownAudit.events);
|
||
setFabricDrilldownAuditSummary(drilldownAudit.summary || null);
|
||
setFabricRebuildLedgerFilters(filters);
|
||
await loadFabricRebuildLedger(true, filters);
|
||
}
|
||
|
||
function fabricRebuildIncidentsForFeedbackBreakdown(item: FabricServiceChannelRouteRebuildFeedbackHealthBreakdown) {
|
||
const reporterNodeIDs = new Set(item.affected_reporter_node_ids || []);
|
||
const routeIDs = new Set(item.affected_route_ids || []);
|
||
return fabricRebuildIncidents.filter((incident) => {
|
||
const channelMatches = !item.feedback_channel_id || incident.channel_id === item.feedback_channel_id;
|
||
const reporterMatches = reporterNodeIDs.size === 0 || reporterNodeIDs.has(incident.reporter_node_id);
|
||
const routeMatches = routeIDs.size === 0 || routeIDs.has(incident.route_id);
|
||
return channelMatches && reporterMatches && routeMatches;
|
||
});
|
||
}
|
||
|
||
function fabricFeedbackBreakdownForAuditEvent(event: AuditEvent) {
|
||
const payload = objectField(event.payload) || {};
|
||
const feedbackSource = stringField(payload, "feedback_source", "");
|
||
const feedbackChannelID = stringField(payload, "feedback_channel_id", "");
|
||
const feedbackViolationStatus = stringField(payload, "feedback_violation_status", "");
|
||
const reporterNodeID = stringField(payload, "reporter_node_id", "");
|
||
const routeID = stringField(payload, "route_id", "");
|
||
if (!feedbackSource && !feedbackChannelID && !feedbackViolationStatus) {
|
||
return null;
|
||
}
|
||
return (
|
||
(fabricRebuildHealth?.feedback_breakdowns || []).find((item) => {
|
||
if (feedbackSource && item.feedback_source !== feedbackSource) {
|
||
return false;
|
||
}
|
||
if (feedbackChannelID && item.feedback_channel_id !== feedbackChannelID) {
|
||
return false;
|
||
}
|
||
if (feedbackViolationStatus && item.feedback_violation_status !== feedbackViolationStatus) {
|
||
return false;
|
||
}
|
||
if (reporterNodeID && !(item.affected_reporter_node_ids || []).includes(reporterNodeID)) {
|
||
return false;
|
||
}
|
||
if (routeID && !(item.affected_route_ids || []).includes(routeID)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}) || null
|
||
);
|
||
}
|
||
|
||
function fabricRebuildIncidentForAuditEvent(event: AuditEvent) {
|
||
const payload = objectField(event.payload) || {};
|
||
const reporterNodeID = stringField(payload, "reporter_node_id", "");
|
||
const routeID = stringField(payload, "route_id", event.target_type === "fabric_service_channel_route_rebuild_incident" ? event.target_id || "" : "");
|
||
const serviceClass = stringField(payload, "service_class", "");
|
||
const generation = stringField(payload, "generation", "");
|
||
const guardStatus = stringField(payload, "guard_status", "");
|
||
return (
|
||
fabricRebuildIncidents.find((incident) => {
|
||
if (reporterNodeID && incident.reporter_node_id !== reporterNodeID) {
|
||
return false;
|
||
}
|
||
if (routeID && incident.route_id !== routeID) {
|
||
return false;
|
||
}
|
||
if (serviceClass && incident.service_class !== serviceClass) {
|
||
return false;
|
||
}
|
||
if (generation && incident.generation !== generation) {
|
||
return false;
|
||
}
|
||
if (guardStatus && incident.guard_status !== guardStatus) {
|
||
return false;
|
||
}
|
||
return Boolean(reporterNodeID || routeID || serviceClass || generation || guardStatus);
|
||
}) || null
|
||
);
|
||
}
|
||
|
||
async function openFabricRebuildFeedbackBreakdownLedger(item: FabricServiceChannelRouteRebuildFeedbackHealthBreakdown) {
|
||
const filters = {
|
||
...defaultFabricRebuildLedgerFilters,
|
||
feedbackSource: item.feedback_source || "",
|
||
feedbackChannelId: item.feedback_channel_id || "",
|
||
feedbackViolationStatus: item.feedback_violation_status || "",
|
||
offset: 0,
|
||
};
|
||
await client.recordFabricServiceChannelRouteRebuildInvestigation(selectedClusterId, {
|
||
reporterNodeId: (item.affected_reporter_node_ids || [])[0] || "",
|
||
routeId: (item.affected_route_ids || [])[0] || "",
|
||
feedbackSource: item.feedback_source || "",
|
||
feedbackChannelId: item.feedback_channel_id || "",
|
||
feedbackViolationStatus: item.feedback_violation_status || "",
|
||
drilldownSource: "rebuild_health_feedback_breakdown",
|
||
reason: "operator opened rebuild-health feedback breakdown ledger",
|
||
});
|
||
const drilldownAudit = await client.getFabricServiceChannelRebuildInvestigationBreadcrumbs(selectedClusterId, { limit: 20 });
|
||
setFabricDrilldownAudit(drilldownAudit.events);
|
||
setFabricDrilldownAuditSummary(drilldownAudit.summary || null);
|
||
setActiveView("fabric");
|
||
setFabricRebuildLedgerFilters(filters);
|
||
await loadFabricRebuildLedger(true, filters);
|
||
}
|
||
|
||
async function silenceFabricRebuildIncident(incident: FabricServiceChannelRouteRebuildIncident) {
|
||
await client.silenceFabricServiceChannelRouteRebuildAlert(selectedClusterId, {
|
||
incidentSource: incident.incident_source || "",
|
||
channelId: incident.channel_id || "",
|
||
reporterNodeId: incident.reporter_node_id,
|
||
routeId: incident.route_id,
|
||
guardStatus: incident.guard_status || "unknown",
|
||
generation: incident.generation || "",
|
||
reason: "operator acknowledged rebuild incident",
|
||
ttlSeconds: 21600,
|
||
});
|
||
await refreshFabricRebuildDiagnostics();
|
||
}
|
||
|
||
async function unsilenceFabricRebuildAlert(silence: FabricServiceChannelRouteRebuildAlertSilence) {
|
||
await client.unsilenceFabricServiceChannelRouteRebuildAlert(
|
||
selectedClusterId,
|
||
silence.id,
|
||
"operator removed rebuild alert silence",
|
||
);
|
||
await refreshFabricRebuildDiagnostics();
|
||
}
|
||
|
||
function clearClusterScope() {
|
||
setNodes([]);
|
||
setNodeGroups([]);
|
||
setJoinRequests([]);
|
||
setJoinTokens([]);
|
||
setReleaseVersions([]);
|
||
setNodeUpdatePlansByNode({});
|
||
setAuthority(null);
|
||
setRolesByNode({});
|
||
setDesiredWorkloadsByNode({});
|
||
setWorkloadsByNode({});
|
||
setHeartbeatsByNode({});
|
||
setNodeUpdateStatusesByNode({});
|
||
setTelemetryByNode({});
|
||
setMeshLinks([]);
|
||
setRouteIntents([]);
|
||
setSyntheticMeshConfigsByNode({});
|
||
setFabricRouteFeedback([]);
|
||
setFabricRebuildAttempts([]);
|
||
setFabricRebuildHealth(null);
|
||
setFabricRebuildSilences([]);
|
||
setFabricReadiness(null);
|
||
setFabricSchemaStatus(null);
|
||
setFabricSnapshotWarmup(null);
|
||
setFabricRebuildIncidents([]);
|
||
setFabricRebuildLedgerDeep(false);
|
||
setFabricRebuildLedgerFilters(defaultFabricRebuildLedgerFilters);
|
||
setQosPolicies([]);
|
||
setEntryPoints([]);
|
||
setEntryPointNodesById({});
|
||
setEgressPools([]);
|
||
setEgressPoolNodesById({});
|
||
setTestingFlags([]);
|
||
setVPNConnections([]);
|
||
setVPNLeases({});
|
||
setVPNPacketStats({});
|
||
setVPNClientDiagnostics([]);
|
||
setVPNClientDiagnostic(null);
|
||
setOrganizations([]);
|
||
setUsers([]);
|
||
setResources([]);
|
||
setMembershipsByOrg({});
|
||
setAudit([]);
|
||
setFabricDrilldownAudit([]);
|
||
setFabricDrilldownAuditSummary(null);
|
||
}
|
||
|
||
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 refreshVPNClientDiagnostic() {
|
||
if (!selectedClusterId) {
|
||
setVPNClientDiagnostic(null);
|
||
return;
|
||
}
|
||
const diagnostics = await client.listVPNClientDiagnosticStatuses(selectedClusterId);
|
||
setVPNClientDiagnostics(diagnostics);
|
||
const selectedDeviceId = vpnDiagnosticDeviceId.trim() || diagnostics[0]?.device_id || "";
|
||
if (selectedDeviceId) {
|
||
localStorage.setItem(storageKeys.vpnDiagnosticDeviceId, selectedDeviceId);
|
||
setVPNDiagnosticDeviceId(selectedDeviceId);
|
||
}
|
||
const diagnostic =
|
||
diagnostics.find((item) => item.device_id === selectedDeviceId) ||
|
||
(selectedDeviceId ? await client.getVPNClientDiagnosticStatus(selectedClusterId, selectedDeviceId) : null);
|
||
setVPNClientDiagnostic(diagnostic);
|
||
setNotice(diagnostic ? "Диагностика VPN-клиента обновлена." : "Диагностика VPN-клиента не найдена.");
|
||
}
|
||
|
||
async function sendVPNDiagnosticCommand(command: Record<string, unknown>, label: string) {
|
||
if (!selectedClusterId) {
|
||
setError("Выбери кластер перед отправкой команды.");
|
||
return;
|
||
}
|
||
const deviceId = vpnDiagnosticDeviceId.trim();
|
||
if (!deviceId) {
|
||
setError("Укажи Android device id или выбери найденный клиент.");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
setNotice("");
|
||
try {
|
||
const queued = await client.enqueueVPNClientDiagnosticCommand(selectedClusterId, deviceId, command);
|
||
setLastVPNDiagnosticCommand(queued);
|
||
setNotice(`${label}: команда поставлена в очередь. Клиент заберет ее через диагностический канал.`);
|
||
window.setTimeout(() => {
|
||
void refreshVPNClientDiagnostic();
|
||
}, 3500);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Команда VPN-клиенту не отправлена.");
|
||
} 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 });
|
||
let nextMode: ConsoleMode = "admin";
|
||
try {
|
||
await accessClient.listClusterSummaries();
|
||
nextMode = "admin";
|
||
} catch {
|
||
try {
|
||
const [loadedOrganizations, loadedResources] = await Promise.all([accessClient.listOrganizations(), accessClient.listResources()]);
|
||
setOrganizations(loadedOrganizations);
|
||
setResources(loadedResources);
|
||
if (loadedOrganizations[0]?.id) {
|
||
setOrganizationId(loadedOrganizations[0].id);
|
||
}
|
||
const membershipEntries = await Promise.all(
|
||
loadedOrganizations.map(async (org) => [org.id, await accessClient.listOrganizationMemberships(org.id)] as const),
|
||
);
|
||
setMembershipsByOrg(Object.fromEntries(membershipEntries));
|
||
nextMode = "user";
|
||
} catch {
|
||
try {
|
||
await authClient.revokeAuthSession({
|
||
userId: nextSession.userId,
|
||
authSessionId: nextSession.authSessionId,
|
||
reason: "user_portal_access_denied",
|
||
});
|
||
} catch {
|
||
// Authentication succeeded, but no accessible workspace was found.
|
||
}
|
||
throw new Error(t.accessDenied);
|
||
}
|
||
}
|
||
setRememberSession(loginForm.rememberMe);
|
||
persistSession(nextSession, loginForm.rememberMe);
|
||
setSession(nextSession);
|
||
setActorUserId(nextSession.userId);
|
||
setLoginForm((previous) => ({ ...previous, email: nextSession.email, password: "" }));
|
||
setSessionRefreshedAt(new Date().toISOString());
|
||
setConsoleMode(nextMode);
|
||
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);
|
||
setRememberSession(false);
|
||
setSessionRefreshedAt("");
|
||
persistSession(null);
|
||
setConsoleMode(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 !== "healthy" || 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 routeIntentActive = routeIntents.filter((item) => routeIntentLifecycle(item) === "active");
|
||
const routeIntentExpired = routeIntents.filter((item) => routeIntentLifecycle(item) === "expired");
|
||
const routeIntentDisabled = routeIntents.filter((item) => routeIntentLifecycle(item) === "disabled");
|
||
const fabricRouteFeedbackVisible = fabricRouteFeedback.filter((item) => {
|
||
const expiresAt = Date.parse(item.expires_at || "");
|
||
const retryUntil = Date.parse(item.retry_cooldown_until || "");
|
||
return (Number.isFinite(expiresAt) && expiresAt > Date.now()) || (Number.isFinite(retryUntil) && retryUntil > Date.now());
|
||
});
|
||
const fabricFeedbackFenced = fabricRouteFeedbackVisible.filter((item) => item.feedback_status === "fenced");
|
||
const fabricFeedbackDegraded = fabricRouteFeedbackVisible.filter((item) => item.feedback_status === "degraded");
|
||
const fabricFeedbackHealthy = fabricRouteFeedbackVisible.filter((item) => item.feedback_status === "healthy");
|
||
const fabricFeedbackRecovered = fabricRouteFeedbackVisible.filter((item) => item.recovery_state === "recovered" || item.recovery_hysteresis_active);
|
||
const fabricFeedbackPromoted = fabricRouteFeedbackVisible.filter((item) => item.recovery_promoted);
|
||
const fabricFeedbackDemoted = fabricRouteFeedbackVisible.filter((item) => item.recovery_demoted);
|
||
const fabricFeedbackRetryCooldown = fabricRouteFeedbackVisible.filter((item) => item.feedback_status === "operator_retry_cooldown" || item.retry_cooldown_until);
|
||
const fabricRouteDecisions = syntheticMeshConfigs.flatMap((config) => config.route_path_decisions?.decisions || []);
|
||
const fabricNoAlternateDecisions = fabricRouteDecisions.filter((decision) => decision.decision_source === "service_channel_feedback_no_alternate");
|
||
const fabricReplacementDecisions = fabricRouteDecisions.filter((decision) => decision.decision_source === "service_channel_feedback_replacement");
|
||
const fabricRebuildDecisions = fabricRouteDecisions.filter((decision) => decision.rebuild_status);
|
||
const fabricRebuildAppliedDecisions = fabricRebuildDecisions.filter((decision) => decision.rebuild_status === "applied");
|
||
const fabricRebuildAppliedAttempts = fabricRebuildAttempts.filter((attempt) => attempt.rebuild_status === "applied");
|
||
const fabricRebuildPendingAttempts = fabricRebuildAttempts.filter((attempt) => attempt.rebuild_status && attempt.rebuild_status !== "applied");
|
||
const fabricRebuildGuardAlerts = fabricRebuildAttempts.filter((attempt) => attempt.guard_severity === "bad");
|
||
const fabricRecoveryHysteresisDecisions = fabricRouteDecisions.filter((decision) => (decision.score_reasons || []).includes("service_channel_recovery_hysteresis"));
|
||
const fabricRecoveryPromotedDecisions = fabricRouteDecisions.filter((decision) => (decision.score_reasons || []).includes("service_channel_recovery_promoted"));
|
||
const fabricRecoveryDemotedDecisions = fabricRouteDecisions.filter((decision) => (decision.score_reasons || []).includes("service_channel_recovery_demoted"));
|
||
const bootstrapRequired = installationStatus?.bootstrapped === false;
|
||
const bootstrapBlocked = bootstrapRequired && !installationStatus?.strict_authority && !installationStatus?.insecure_bootstrap_allowed;
|
||
const sessionModeLabel = consoleMode === "admin" ? t.sessionModeAdmin : t.sessionModeUser;
|
||
|
||
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.trim() })}
|
||
autoComplete="username"
|
||
autoCapitalize="none"
|
||
autoCorrect="off"
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<label>
|
||
{t.password}
|
||
<input
|
||
value={loginForm.password}
|
||
onChange={(event) => setLoginForm({ ...loginForm, password: event.target.value })}
|
||
type={loginForm.showPassword ? "text" : "password"}
|
||
autoComplete="current-password"
|
||
autoCapitalize="none"
|
||
autoCorrect="off"
|
||
spellCheck={false}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
void handleLogin();
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={loginForm.showPassword}
|
||
onChange={(event) => setLoginForm({ ...loginForm, showPassword: event.target.checked })}
|
||
/>
|
||
Показать пароль
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={loginForm.trustDevice}
|
||
onChange={(event) => setLoginForm({ ...loginForm, trustDevice: event.target.checked })}
|
||
/>
|
||
{t.trustDevice}
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={loginForm.rememberMe}
|
||
onChange={(event) => setLoginForm({ ...loginForm, rememberMe: event.target.checked })}
|
||
/>
|
||
{t.rememberMe}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
if (session && !consoleMode) {
|
||
return (
|
||
<main className="loginShell">
|
||
<section className="loginCard">
|
||
<p>{loading ? t.lastRefresh : "Восстанавливаем сессию..."}</p>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (consoleMode === "user") {
|
||
const activeOrganization = organizations.find((org) => org.id === organizationId) || organizations[0] || null;
|
||
const visibleResources = activeOrganization ? resources.filter((resource) => resource.organization_id === activeOrganization.id) : resources;
|
||
const activeMembership = activeOrganization
|
||
? (membershipsByOrg[activeOrganization.id] || []).find((membership) => membership.user_id === session.userId)
|
||
: null;
|
||
const protocolCounts = visibleResources.reduce<Record<string, number>>((acc, resource) => {
|
||
acc[resource.protocol] = (acc[resource.protocol] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
return (
|
||
<main className="portalShell">
|
||
<aside className="portalRail">
|
||
<div className="brandMark">RAP</div>
|
||
<p className="sideKicker">Личный кабинет</p>
|
||
<h1>Мой доступ</h1>
|
||
<p className="sideText">Установки, доступные серверы и состояние рабочей области пользователя.</p>
|
||
<StateLine label={t.sessionMode} value={`${sessionModeLabel} • ${sessionRefreshedAt ? formatTime(sessionRefreshedAt) : "н/д"}`} />
|
||
<StateLine label={t.actorUser} value={session.email} />
|
||
<button className="ghost" onClick={() => void handleLogout()} disabled={loading}>
|
||
{t.logout}
|
||
</button>
|
||
</aside>
|
||
<section className="portalWorkspace">
|
||
<header className="portalTop">
|
||
<div>
|
||
<p className="eyebrow">Secure Access Fabric</p>
|
||
<h2>{activeOrganization?.name || "Личный кабинет"}</h2>
|
||
<p className="muted">{session.email}</p>
|
||
</div>
|
||
<label>
|
||
Организация
|
||
<select value={activeOrganization?.id || ""} onChange={(event) => setOrganizationId(event.target.value)}>
|
||
{organizations.map((org) => (
|
||
<option key={org.id} value={org.id}>
|
||
{org.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button className="primary" onClick={() => void refreshUserPortal()} disabled={loading}>
|
||
{loading ? t.refreshing : t.refresh}
|
||
</button>
|
||
</header>
|
||
|
||
{error && <div className="errorPanel">{error}</div>}
|
||
{notice && <div className="noticePanel">{notice}</div>}
|
||
|
||
<section className="grid three">
|
||
<Metric label="Организации" value={organizations.length} tone="steel" />
|
||
<Metric label="Серверы" value={visibleResources.length} tone="green" />
|
||
<Metric label="Установки" value={2} tone="amber" />
|
||
</section>
|
||
|
||
<section className="grid two">
|
||
<article className="card">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Установки</h3>
|
||
<p className="muted">
|
||
{androidClientVersion ? `Актуальная версия Android: ${androidClientVersion}` : "Скачивайте актуальные клиенты только отсюда, чтобы не ловить старую сборку."}
|
||
</p>
|
||
</div>
|
||
<span className="status active">latest</span>
|
||
</div>
|
||
<div className="portalInstallList">
|
||
<a className="installTile primaryInstall" href={androidVPNDownloadUrl}>
|
||
<strong>Android VPN</strong>
|
||
<span>Последняя сборка RAP HOME VPN для телефона</span>
|
||
<small>{androidClientVersionedPath || portalAndroidArtifactPath}</small>
|
||
</a>
|
||
<a className="installTile" href={`${portalDownloadBaseUrl}/downloads/rap-windows-rdp-client-latest-win-x64.zip`}>
|
||
<strong>Windows RDP клиент</strong>
|
||
<span>Клиент удаленного рабочего стола, когда нужен доступ к серверам</span>
|
||
<small>latest win-x64</small>
|
||
</a>
|
||
</div>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Профиль</h3>
|
||
<StateLine label="Пользователь" value={session.email} />
|
||
<StateLine label="Роль в организации" value={activeMembership?.role_id || "участник"} />
|
||
<StateLine label="Организация" value={activeOrganization?.name || "нет"} />
|
||
<StateLine label="Последнее обновление" value={lastDataRefreshAt ? formatDate(lastDataRefreshAt) : "нет"} />
|
||
</article>
|
||
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Доступные серверы</h3>
|
||
<p className="muted">Список ресурсов, которые уже разрешены пользователю через организацию.</p>
|
||
</div>
|
||
</div>
|
||
<DataTable
|
||
columns={["имя", "адрес", "протокол", "секрет", "передача файлов"]}
|
||
rows={visibleResources.map((resource) => [
|
||
resource.name,
|
||
resource.address,
|
||
resource.protocol,
|
||
resource.has_secret ? "настроен" : "нет",
|
||
statusLabel(resource.file_transfer_mode || "disabled"),
|
||
])}
|
||
/>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Сервисы</h3>
|
||
<DataTable columns={["тип", "количество"]} rows={Object.entries(protocolCounts).map(([protocol, count]) => [protocol, String(count)])} />
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Что здесь будет дальше</h3>
|
||
<div className="portalRoadmap">
|
||
<span>Устройства и доверенные входы</span>
|
||
<span>Активные VPN/RDP сессии</span>
|
||
<span>Обновление профиля VPN без ручных ключей</span>
|
||
<span>Самостоятельная смена пароля</span>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</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 !== "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="refreshStatus">
|
||
<strong>{t.autoRefresh}</strong>
|
||
<span>{lastDataRefreshAt ? `${t.lastRefresh}: ${formatTime(lastDataRefreshAt)} / ${liveTransport.toUpperCase()}` : liveTransport.toUpperCase()}</span>
|
||
</div>
|
||
<div className="profilePanel">
|
||
<strong>{t.profile}</strong>
|
||
<span>{session.email}</span>
|
||
<span>
|
||
{t.sessionMode}: {sessionModeLabel} | {t.sessionRefreshedAt}: {sessionRefreshedAt ? formatTime(sessionRefreshedAt) : "н/д"}
|
||
</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 runtime = nodeRuntimeConnectivity(displayNode, heartbeatsByNode[displayNode.id] || [], meshLinks);
|
||
const update = nodeUpdateSummary(displayNode, nodeUpdatePlansByNode[displayNode.id], releaseVersions);
|
||
const updater = nodeUpdaterSummary(nodeUpdateStatusesByNode[displayNode.id] || []);
|
||
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>
|
||
<small className="muted">{runtime.address}</small>
|
||
</div>
|
||
<Status value={displayNode.health_status} />
|
||
<RuntimeBadges runtime={runtime} />
|
||
<div className="nodeEndpointCell">
|
||
<strong>{displayNode.reported_version || "версия неизвестна"}</strong>
|
||
<small>{update.targetLabel}</small>
|
||
</div>
|
||
<Status value={update.status} />
|
||
<div className="nodeEndpointCell">
|
||
<strong className={`pill ${updater.tone}`}>{updater.label}</strong>
|
||
<small>{updater.detail}</small>
|
||
</div>
|
||
<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>
|
||
<button
|
||
className="danger"
|
||
onClick={() =>
|
||
confirmHighRisk(`Удалить узел ${displayNode.name} из кластера`) &&
|
||
void runAction(
|
||
() => client.deleteClusterNode(selectedClusterId, displayNode.id, "Удалено из списка узлов панели владельца платформы."),
|
||
"Узел удален из кластера.",
|
||
)
|
||
}
|
||
>
|
||
Удалить
|
||
</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))} />
|
||
{roleDisplayLabel(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" && (
|
||
<NodeDetailsDashboard
|
||
node={node}
|
||
memberships={nodeInfoDialog.memberships}
|
||
activeRoles={activeRoles}
|
||
desiredWorkloads={desiredWorkloads}
|
||
observedWorkloads={observedWorkloads}
|
||
heartbeats={heartbeatsByNode[node.id] || []}
|
||
telemetry={telemetryByNode[node.id] || []}
|
||
updatePlan={nodeUpdatePlansByNode[node.id]}
|
||
updateStatuses={nodeUpdateStatusesByNode[node.id] || []}
|
||
meshLinks={meshLinks.filter((link) => link.source_node_id === node.id || link.target_node_id === node.id)}
|
||
syntheticConfig={syntheticMeshConfigsByNode[node.id]}
|
||
allNodes={nodes}
|
||
onSetUpdatePolicy={(targetNode, product, targetVersion) =>
|
||
void runAction(async () => {
|
||
await client.upsertNodeUpdatePolicy(selectedClusterId, targetNode.id, {
|
||
product,
|
||
channel: "dev",
|
||
targetVersion,
|
||
strategy: targetVersion ? "rolling" : "rolling",
|
||
enabled: true,
|
||
rollbackAllowed: true,
|
||
healthWindowSeconds: 180,
|
||
});
|
||
}, targetVersion ? `${product} поставлен в target ${targetVersion}.` : `${product} будет следовать latest dev.`)
|
||
}
|
||
labels={t}
|
||
/>
|
||
)}
|
||
|
||
{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 || "not_configured";
|
||
const observedState = observed?.reported_state || "missing";
|
||
const enabled = Boolean(activeRole) && desiredState === "enabled";
|
||
return (
|
||
<div className="functionRow" key={role}>
|
||
<div className="nodeListMain">
|
||
<strong>{roleDisplayLabel(role)}</strong>
|
||
<span>{capabilityOptionLabel(role, latestHeartbeat, language)}</span>
|
||
</div>
|
||
<FunctionState label={t.rolePermission} value={activeRole ? t.permissionGranted : t.permissionDenied} tone={activeRole ? "info" : ""} />
|
||
<FunctionState label={t.desiredRuntime} value={statusLabel(desiredState)} tone={desiredState === "enabled" ? "good" : ""} />
|
||
<FunctionState label={t.observedRuntime} value={statusLabel(observedState)} tone={observedState === "running" ? "good" : observedState === "missing" ? "warn" : ""} />
|
||
<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>
|
||
{(() => {
|
||
const listenerDesired = desiredWorkloads.find((workload) => workload.service_type === "mesh-listener");
|
||
const listenerConfig = (listenerDesired?.config || {}) as Record<string, unknown>;
|
||
const draft =
|
||
meshListenerDrafts[node.id] || {
|
||
listenAddr: String(listenerConfig.listen_addr || ":19131"),
|
||
mode: String(listenerConfig.listen_port_mode || "auto"),
|
||
autoRange: `${Number(listenerConfig.auto_port_start || 19131)}-${Number(listenerConfig.auto_port_end || 19231)}`,
|
||
advertiseEndpoint: String(listenerConfig.advertise_endpoint || ""),
|
||
advertiseTransport: String(listenerConfig.advertise_transport || "direct_http"),
|
||
connectivity: String(listenerConfig.connectivity_mode || "private_lan"),
|
||
nat: String(listenerConfig.nat_type || "none"),
|
||
region: String(listenerConfig.region || ""),
|
||
};
|
||
const setDraft = (patch: Partial<typeof draft>) => setMeshListenerDrafts({ ...meshListenerDrafts, [node.id]: { ...draft, ...patch } });
|
||
return (
|
||
<section className="nodePanel nestedPanel">
|
||
<h4>Mesh listener</h4>
|
||
<FormGrid>
|
||
<label>
|
||
Listen addr
|
||
<input value={draft.listenAddr} onChange={(event) => setDraft({ listenAddr: event.target.value })} placeholder="0.0.0.0:19131 или :19131" />
|
||
</label>
|
||
<label>
|
||
Port mode
|
||
<select value={draft.mode} onChange={(event) => setDraft({ mode: event.target.value })}>
|
||
<option value="auto">auto</option>
|
||
<option value="manual">manual</option>
|
||
<option value="disabled">disabled</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Auto ports
|
||
<input value={draft.autoRange} onChange={(event) => setDraft({ autoRange: event.target.value })} placeholder="19131-19231" />
|
||
</label>
|
||
<label>
|
||
Advertise endpoint
|
||
<input
|
||
value={draft.advertiseEndpoint}
|
||
onChange={(event) => setDraft({ advertiseEndpoint: event.target.value })}
|
||
placeholder="http://external-or-lan-ip:19131"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Advertise transport
|
||
<select value={draft.advertiseTransport} onChange={(event) => setDraft({ advertiseTransport: event.target.value })}>
|
||
<option value="direct_http">direct_http</option>
|
||
<option value="direct_https">direct_https</option>
|
||
<option value="wss">wss</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Connectivity
|
||
<select value={draft.connectivity} onChange={(event) => setDraft({ connectivity: event.target.value })}>
|
||
<option value="private_lan">private_lan</option>
|
||
<option value="direct">direct</option>
|
||
<option value="outbound_only">outbound_only</option>
|
||
<option value="relay_required">relay_required</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
NAT
|
||
<select value={draft.nat} onChange={(event) => setDraft({ nat: event.target.value })}>
|
||
<option value="none">none</option>
|
||
<option value="unknown">unknown</option>
|
||
<option value="port_restricted">port_restricted</option>
|
||
<option value="symmetric">symmetric</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Region/site
|
||
<input value={draft.region} onChange={(event) => setDraft({ region: event.target.value })} placeholder="dc1, office, docker-test" />
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const [startRaw, endRaw] = draft.autoRange.split("-").map((value) => Number(value.trim()));
|
||
const start = Number.isFinite(startRaw) ? startRaw : 19131;
|
||
const end = Number.isFinite(endRaw) ? endRaw : start;
|
||
await client.setDesiredWorkload(selectedClusterId, node.id, "mesh-listener", {
|
||
desiredState: draft.mode === "disabled" ? "disabled" : "enabled",
|
||
version: `listener-${Date.now()}`,
|
||
runtimeMode: "container",
|
||
config: {
|
||
listen_addr: draft.listenAddr,
|
||
listen_port_mode: draft.mode,
|
||
auto_port_start: start,
|
||
auto_port_end: end,
|
||
advertise_endpoint: draft.advertiseEndpoint.trim().replace(/\/$/, "") || null,
|
||
advertise_transport: draft.advertiseTransport || "direct_http",
|
||
connectivity_mode: draft.connectivity,
|
||
nat_type: draft.nat,
|
||
region: draft.region || null,
|
||
},
|
||
environment: {},
|
||
});
|
||
}, "Mesh listener config обновлен.")
|
||
}
|
||
>
|
||
Применить listener
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
})()}
|
||
<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))}
|
||
/>
|
||
{roleDisplayLabel(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 = activeRoleAssignments(rolesByNode[node.id] || []);
|
||
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>{roleDisplayLabel(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}>
|
||
{roleDisplayLabel(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}>
|
||
{roleDisplayLabel(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={["состояние", "версия", "listener", "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 || "неизвестно",
|
||
meshListenerSummary(heartbeat),
|
||
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>
|
||
<label>
|
||
Имя нового узла
|
||
<input
|
||
value={joinTokenForm.nodeName}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, nodeName: event.target.value })}
|
||
placeholder={suggestedInstallNodeName(joinTokenForm, selectedCluster)}
|
||
/>
|
||
<small>Если оставить пустым, панель подставит имя автоматически.</small>
|
||
</label>
|
||
<label>
|
||
Группа узла
|
||
<select value={joinTokenForm.nodeGroupId} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, nodeGroupId: event.target.value })}>
|
||
<option value="">Без группы</option>
|
||
{nodeGroups.map((group) => (
|
||
<option key={group.id} value={group.id}>
|
||
{groupPathLabel(group, nodeGroups)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</FormGrid>
|
||
<section className="nodePanel">
|
||
<h4>Install profile</h4>
|
||
<p className="muted">Эти поля попадут в install profile. Для Windows без админ-прав будет создан user startup task, с админ-правами - system startup task.</p>
|
||
<div className="segmented">
|
||
{[
|
||
["docker", "Docker Linux"],
|
||
["linux_binary", "Ubuntu service"],
|
||
["windows_service", "Windows"],
|
||
].map(([mode, label]) => (
|
||
<button
|
||
type="button"
|
||
key={mode}
|
||
className={joinTokenForm.installMode === mode ? "active" : ""}
|
||
onClick={() => setJoinTokenForm({ ...joinTokenForm, installMode: mode })}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="segmented">
|
||
{[
|
||
["private_lan", "LAN"],
|
||
["direct", "Public"],
|
||
["nat_forward", "NAT"],
|
||
["outbound_only", "Outbound"],
|
||
].map(([mode, label]) => (
|
||
<button
|
||
type="button"
|
||
key={mode}
|
||
className={dockerConnectivityPreset(joinTokenForm) === mode ? "active" : ""}
|
||
onClick={() => setJoinTokenForm(applyDockerConnectivityPreset(joinTokenForm, mode))}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
Control-plane endpoint
|
||
<input
|
||
value={joinTokenForm.controlPlaneEndpoint}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, controlPlaneEndpoint: event.target.value })}
|
||
placeholder={defaultControlPlaneEndpoint()}
|
||
/>
|
||
</label>
|
||
<label>
|
||
{joinTokenForm.installMode === "windows_service" ? "Windows node-agent artifact" : joinTokenForm.installMode === "linux_binary" ? "Linux node-agent artifact" : "Docker image"}
|
||
<input value={joinTokenForm.dockerImage} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, dockerImage: event.target.value })} />
|
||
</label>
|
||
{joinTokenForm.installMode === "windows_service" && (
|
||
<>
|
||
<label>
|
||
Windows startup
|
||
<select value={joinTokenForm.windowsStartupMode} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, windowsStartupMode: event.target.value })}>
|
||
<option value="auto">auto: system task, fallback user task</option>
|
||
<option value="system-task">system task, admin required</option>
|
||
<option value="user-task">user task, no admin</option>
|
||
<option value="none">none</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Install dir
|
||
<input
|
||
value={joinTokenForm.windowsInstallDir}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, windowsInstallDir: event.target.value })}
|
||
placeholder="C:\\Program Files\\RAP\\node-name"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Windows node-agent SHA256
|
||
<input
|
||
value={joinTokenForm.windowsNodeAgentSHA256}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, windowsNodeAgentSHA256: event.target.value })}
|
||
placeholder="опционально, но желательно для production"
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
{joinTokenForm.installMode === "linux_binary" && (
|
||
<>
|
||
<label>
|
||
Linux install dir
|
||
<input
|
||
value={joinTokenForm.linuxInstallDir}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, linuxInstallDir: event.target.value })}
|
||
placeholder="/opt/rap/node-name"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Linux node-agent SHA256
|
||
<input
|
||
value={joinTokenForm.linuxNodeAgentSHA256}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, linuxNodeAgentSHA256: event.target.value })}
|
||
placeholder="опционально, но желательно для production"
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
{joinTokenForm.installMode === "docker" && <label>
|
||
Container name
|
||
<input
|
||
value={joinTokenForm.dockerContainerName}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, dockerContainerName: event.target.value })}
|
||
placeholder={suggestedInstallContainerName(joinTokenForm, selectedCluster)}
|
||
/>
|
||
</label>}
|
||
<label>
|
||
Artifact endpoints
|
||
<input
|
||
value={joinTokenForm.artifactEndpoints}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, artifactEndpoints: event.target.value })}
|
||
placeholder={defaultArtifactEndpoint()}
|
||
/>
|
||
<small>Через запятую: public/LAN/cache узлы, где host-agent сможет скачать image tar до входа в mesh.</small>
|
||
</label>
|
||
{joinTokenForm.installMode === "docker" && <label>
|
||
Docker image tar SHA256
|
||
<input
|
||
value={joinTokenForm.dockerImageArtifactSHA256}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, dockerImageArtifactSHA256: event.target.value })}
|
||
placeholder="опционально, но желательно для production"
|
||
/>
|
||
</label>}
|
||
{joinTokenForm.installMode === "docker" && <label>
|
||
Docker network
|
||
<select value={joinTokenForm.dockerNetwork} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, dockerNetwork: event.target.value })}>
|
||
<option value="host">host</option>
|
||
<option value="bridge">bridge</option>
|
||
</select>
|
||
</label>}
|
||
<label>
|
||
Listen addr
|
||
<input value={joinTokenForm.meshListenAddr} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, meshListenAddr: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Listen mode
|
||
<select
|
||
value={joinTokenForm.meshListenPortMode}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, meshListenPortMode: event.target.value })}
|
||
>
|
||
<option value="auto">auto</option>
|
||
<option value="manual">manual</option>
|
||
<option value="disabled">disabled</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Auto ports
|
||
<input
|
||
value={`${joinTokenForm.meshListenAutoPortStart}-${joinTokenForm.meshListenAutoPortEnd}`}
|
||
onChange={(event) => {
|
||
const [start, end] = event.target.value.split("-").map((value) => Number(value.trim()));
|
||
setJoinTokenForm({
|
||
...joinTokenForm,
|
||
meshListenAutoPortStart: Number.isFinite(start) ? start : joinTokenForm.meshListenAutoPortStart,
|
||
meshListenAutoPortEnd: Number.isFinite(end) ? end : joinTokenForm.meshListenAutoPortEnd,
|
||
});
|
||
}}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Advertise endpoint
|
||
<input
|
||
value={joinTokenForm.meshAdvertiseEndpoint}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, meshAdvertiseEndpoint: event.target.value })}
|
||
placeholder="http://public-or-private-ip:19131"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Connectivity
|
||
<select
|
||
value={joinTokenForm.meshConnectivityMode}
|
||
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, meshConnectivityMode: event.target.value })}
|
||
>
|
||
<option value="direct">direct</option>
|
||
<option value="private_lan">private_lan</option>
|
||
<option value="outbound_only">outbound_only</option>
|
||
<option value="relay_required">relay_required</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
NAT
|
||
<select value={joinTokenForm.meshNATType} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, meshNATType: event.target.value })}>
|
||
<option value="none">none</option>
|
||
<option value="unknown">unknown</option>
|
||
<option value="full_cone">full_cone</option>
|
||
<option value="port_restricted">port_restricted</option>
|
||
<option value="symmetric">symmetric</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Region/site
|
||
<input value={joinTokenForm.meshRegion} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, meshRegion: event.target.value })} />
|
||
</label>
|
||
{joinTokenForm.installMode === "docker" && <label className="checkLine">
|
||
<input type="checkbox" checked={joinTokenForm.pullImage} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, pullImage: event.target.checked })} />
|
||
Pull image
|
||
</label>}
|
||
<label className="checkLine">
|
||
<input type="checkbox" checked={joinTokenForm.replace} onChange={(event) => setJoinTokenForm({ ...joinTokenForm, replace: event.target.checked })} />
|
||
Replace existing install
|
||
</label>
|
||
</FormGrid>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{t.suggestedRoles}</h4>
|
||
<p className="muted">
|
||
Роли записываются в install token и автоматически назначаются узлу при approval. После создания token изменение чекбоксов не меняет уже выданный 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) })}
|
||
/>
|
||
{roleDisplayLabel(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 создан.")
|
||
}
|
||
>
|
||
Создать install 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>
|
||
<strong>Scope выданного token</strong>
|
||
<pre className="codePreview">{JSON.stringify(lastJoinToken.scope, null, 2)}</pre>
|
||
<strong>Docker host-agent install</strong>
|
||
<pre className="codePreview">{hostAgentDockerInstallCommand(lastJoinToken, selectedCluster, lastJoinTokenInstallForm)}</pre>
|
||
<strong>Profile-based Docker install</strong>
|
||
<pre className="codePreview">{hostAgentDockerProfileInstallCommand(lastJoinToken, selectedCluster, lastJoinTokenInstallForm)}</pre>
|
||
<strong>Profile-based Ubuntu service install</strong>
|
||
<pre className="codePreview">{hostAgentLinuxProfileInstallCommand(lastJoinToken, selectedCluster, lastJoinTokenInstallForm)}</pre>
|
||
<strong>Profile-based Windows PowerShell install</strong>
|
||
<pre className="codePreview">{hostAgentWindowsProfileInstallCommand(lastJoinToken, selectedCluster, lastJoinTokenInstallForm)}</pre>
|
||
<strong>Profile-based Windows CMD install</strong>
|
||
<pre className="codePreview">{hostAgentWindowsCmdProfileInstallCommand(lastJoinToken, selectedCluster, lastJoinTokenInstallForm)}</pre>
|
||
</div>
|
||
)}
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Как добавить узел</h3>
|
||
<div className="stateList">
|
||
<div className="stateLine">
|
||
<span>1</span>
|
||
<strong>Заполните Docker install profile слева.</strong>
|
||
</div>
|
||
<div className="stateLine">
|
||
<span>2</span>
|
||
<strong>Нажмите “Создать install token”.</strong>
|
||
</div>
|
||
<div className="stateLine">
|
||
<span>3</span>
|
||
<strong>Скопируйте “Profile-based Docker install” и выполните на Docker-хосте.</strong>
|
||
</div>
|
||
<div className="stateLine">
|
||
<span>4</span>
|
||
<strong>Подтвердите join request в этой же вкладке.</strong>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Install tokens</h3>
|
||
<DataTable
|
||
columns={["scope", "status", "uses", "expires", "created", "action"]}
|
||
rows={joinTokens.map((token) => [
|
||
joinTokenName(token),
|
||
statusLabel(token.status),
|
||
`${token.used_count}/${token.max_uses}`,
|
||
formatDate(token.expires_at),
|
||
formatDate(token.created_at),
|
||
token.status === "active" ? (
|
||
<button
|
||
className="danger"
|
||
onClick={() =>
|
||
confirmHighRisk(`Отозвать install token ${shortId(token.id)}`) &&
|
||
void runAction(() => client.revokeJoinToken(selectedClusterId, token.id), "Install token отозван.")
|
||
}
|
||
>
|
||
Отозвать
|
||
</button>
|
||
) : (
|
||
<span className="muted">{statusLabel(token.status)}</span>
|
||
),
|
||
])}
|
||
/>
|
||
</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}>
|
||
{roleDisplayLabel(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}>
|
||
{roleDisplayLabel(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}
|
||
syntheticMeshConfigsByNode={syntheticMeshConfigsByNode}
|
||
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">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Route intents lifecycle</h3>
|
||
<p className="muted">
|
||
Operator view for temporary fabric routes. Expired and disabled intents are not emitted into node-scoped synthetic config.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className="pill good">active {routeIntentActive.length}</span>
|
||
<span className={routeIntentExpired.length > 0 ? "pill warn" : "pill"}>expired {routeIntentExpired.length}</span>
|
||
<span className="pill">disabled {routeIntentDisabled.length}</span>
|
||
</div>
|
||
</div>
|
||
<DataTable
|
||
columns={["route", "life", "service", "priority", "source", "destination", "expires", "updated", "actions"]}
|
||
rows={routeIntents.slice(0, 120).map((item) => {
|
||
const lifecycle = routeIntentLifecycle(item);
|
||
return [
|
||
shortId(item.id),
|
||
<span className={`pill ${routeIntentTone(item)}`}>{lifecycle}</span>,
|
||
item.service_class,
|
||
String(item.priority),
|
||
routeIntentNodeSelector(item.source_selector || {}),
|
||
routeIntentNodeSelector(item.destination_selector || {}),
|
||
item.policy_expires_at ? formatDate(item.policy_expires_at) : "нет",
|
||
formatDate(item.updated_at),
|
||
<div className="inlineActions">
|
||
{lifecycle === "active" ? (
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.expireRouteIntent(selectedClusterId, item.id, "operator expired stale route intent"),
|
||
"Route intent expired.",
|
||
)
|
||
}
|
||
>
|
||
expire
|
||
</button>
|
||
) : (
|
||
<span className="muted">expire</span>
|
||
)}
|
||
{lifecycle !== "disabled" ? (
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.disableRouteIntent(selectedClusterId, item.id, "operator disabled route intent"),
|
||
"Route intent disabled.",
|
||
)
|
||
}
|
||
>
|
||
disable
|
||
</button>
|
||
) : (
|
||
<span className="muted">disable</span>
|
||
)}
|
||
</div>,
|
||
];
|
||
})}
|
||
/>
|
||
{routeIntents.length === 0 && <EmptyState title="Route intents отсутствуют" text="Нет настроенных fabric route intents для текущего кластера." />}
|
||
</article>
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Service-channel route feedback</h3>
|
||
<p className="muted">
|
||
Cluster-level runtime feedback from the shared fabric channel. Fenced and no-alternate cases affect route selection for any service class.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={fabricFeedbackFenced.length > 0 ? "pill bad" : "pill good"}>fenced {fabricFeedbackFenced.length}</span>
|
||
<span className={fabricFeedbackDegraded.length > 0 ? "pill warn" : "pill"}>degraded {fabricFeedbackDegraded.length}</span>
|
||
<span className={fabricFeedbackRetryCooldown.length > 0 ? "pill warn" : "pill"}>retry {fabricFeedbackRetryCooldown.length}</span>
|
||
<span className={fabricFeedbackRecovered.length > 0 ? "pill warn" : "pill"}>recovered {fabricFeedbackRecovered.length}</span>
|
||
<span className={fabricFeedbackPromoted.length > 0 ? "pill good" : "pill"}>promoted {fabricFeedbackPromoted.length}</span>
|
||
<span className={fabricFeedbackDemoted.length > 0 ? "pill bad" : "pill"}>demoted {fabricFeedbackDemoted.length}</span>
|
||
<span className="pill good">healthy {fabricFeedbackHealthy.length}</span>
|
||
<span className={fabricNoAlternateDecisions.length > 0 ? "pill bad" : "pill"}>no alternate {fabricNoAlternateDecisions.length}</span>
|
||
<span className={fabricRecoveryHysteresisDecisions.length > 0 ? "pill warn" : "pill"}>hysteresis {fabricRecoveryHysteresisDecisions.length}</span>
|
||
<span className={fabricRecoveryPromotedDecisions.length > 0 ? "pill good" : "pill"}>promoted paths {fabricRecoveryPromotedDecisions.length}</span>
|
||
<span className={fabricRecoveryDemotedDecisions.length > 0 ? "pill bad" : "pill"}>demoted paths {fabricRecoveryDemotedDecisions.length}</span>
|
||
<span className={(fabricRecoveryPolicy?.fingerprint || "").length > 0 ? "pill good" : "pill warn"}>policy fp {fabricRecoveryPolicy?.fingerprint ? shortId(fabricRecoveryPolicy.fingerprint) : "нет"}</span>
|
||
<span className={fabricRebuildDecisions.length > fabricRebuildAppliedDecisions.length ? "pill warn" : "pill good"}>
|
||
rebuild {fabricRebuildAppliedDecisions.length}/{fabricRebuildDecisions.length}
|
||
</span>
|
||
<span className={fabricRebuildPendingAttempts.length > 0 ? "pill warn" : "pill good"}>
|
||
ledger {fabricRebuildAppliedAttempts.length}/{fabricRebuildAttempts.length}
|
||
</span>
|
||
<span className={fabricRebuildGuardAlerts.length > 0 ? "pill bad" : "pill good"}>guard {fabricRebuildGuardAlerts.length}</span>
|
||
<span className={fabricRebuildLedgerDeep ? "pill info" : "pill"}>{fabricRebuildLedgerDeep ? "deep ledger" : "fast ledger"}</span>
|
||
</div>
|
||
</div>
|
||
{fabricNoAlternateDecisions.length > 0 && (
|
||
<div className="noticePanel">
|
||
Есть service-channel route без unfenced alternate. Для production-сервиса это означает деградацию: fabric не нашел безопасную замену и будет ждать нового маршрута или операторского решения.
|
||
</div>
|
||
)}
|
||
{fabricSchemaStatus && (
|
||
<div className={`noticePanel ${fabricSchemaStatus.status === "blocked" ? "badPanel" : "goodPanel"}`}>
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Fabric schema preflight</h4>
|
||
<p className="muted">
|
||
Backend/runtime compatibility check for manual deploys before diagnostics or service channels depend on new DB fields.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={`pill ${fabricSchemaStatus.status === "blocked" ? "bad" : "good"}`}>
|
||
{statusLabel(fabricSchemaStatus.status)}
|
||
</span>
|
||
<span className={fabricSchemaStatus.missing_check_count > 0 ? "pill bad" : "pill good"}>
|
||
{fabricSchemaStatus.passed_check_count}/{fabricSchemaStatus.required_check_count}
|
||
</span>
|
||
<button className="ghost" onClick={() => void warmupFabricRebuildSnapshots()} disabled={loading}>
|
||
warm snapshots
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="reason" value={statusLabel(fabricSchemaStatus.reason)} />
|
||
<StateLine label="required" value={fabricSchemaStatus.required_migration} />
|
||
<StateLine label="missing" value={(fabricSchemaStatus.missing_checks || []).map((item) => item.check_id).slice(0, 4).join(", ") || "нет"} />
|
||
<StateLine label="action" value={fabricSchemaStatus.recommended_operator_action || "schema is compatible"} />
|
||
{fabricSnapshotWarmup && (
|
||
<StateLine
|
||
label="warmup"
|
||
value={`warmed ${fabricSnapshotWarmup.warmed_count}, fresh ${fabricSnapshotWarmup.already_fresh_count}, missing ${fabricSnapshotWarmup.missing_snapshot_count}, stale ${fabricSnapshotWarmup.stale_snapshot_count}, deferred ${fabricSnapshotWarmup.deferred_stale_count}, errors ${fabricSnapshotWarmup.error_count}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{fabricSnapshotHealth && (
|
||
<div className={`noticePanel ${fabricSnapshotHealth.status === "degraded" ? "warnPanel" : "goodPanel"}`}>
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Snapshot maintenance</h4>
|
||
<p className="muted">
|
||
Auto-warmup visibility for rebuild snapshot cache after node heartbeats.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={`pill ${fabricSnapshotHealth.status === "degraded" ? "warn" : "good"}`}>
|
||
{statusLabel(fabricSnapshotHealth.status)}
|
||
</span>
|
||
<span className={fabricSnapshotHealth.overdue_missing_snapshot_count > 0 ? "pill bad" : "pill good"}>
|
||
overdue {fabricSnapshotHealth.overdue_missing_snapshot_count}
|
||
</span>
|
||
<span className={fabricSnapshotHealth.auto_warmup_error_count > 0 ? "pill bad" : "pill good"}>
|
||
auto errors {fabricSnapshotHealth.auto_warmup_error_count}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="reason" value={statusLabel(fabricSnapshotHealth.reason)} />
|
||
<StateLine label="snapshots" value={`valid ${fabricSnapshotHealth.valid_snapshot_count}, missing ${fabricSnapshotHealth.missing_snapshot_count}, attempts ${fabricSnapshotHealth.recent_attempt_count}`} />
|
||
<StateLine label="auto-warmup" value={`events ${fabricSnapshotHealth.auto_warmup_event_count}, warmed ${fabricSnapshotHealth.auto_warmup_warmed_count}, fresh ${fabricSnapshotHealth.auto_warmup_already_fresh_count}, latest ${formatDate(fabricSnapshotHealth.latest_auto_warmup_at)}`} />
|
||
<StateLine label="guard" value={`age ${fabricSnapshotHealth.min_age_seconds}s, heartbeats ${fabricSnapshotHealth.heartbeat_threshold}`} />
|
||
<StateLine label="action" value={fabricSnapshotHealth.recommended_operator_action || "snapshot maintenance is current"} />
|
||
</div>
|
||
{(fabricSnapshotHealth.nodes || []).length > 0 && (
|
||
<DataTable
|
||
columns={["node", "snapshots", "heartbeat", "auto-warmup", "latest"]}
|
||
rows={(fabricSnapshotHealth.nodes || []).slice(0, 6).map((item) => [
|
||
nodeName(nodes, item.node_id),
|
||
<span className={item.overdue_missing_snapshot_count > 0 ? "pill bad" : item.missing_snapshot_count > 0 ? "pill warn" : "pill good"}>
|
||
{item.valid_snapshot_count}/{item.recent_attempt_count} overdue {item.overdue_missing_snapshot_count}
|
||
</span>,
|
||
item.heartbeat_after_attempt_count,
|
||
`${item.auto_warmup_warmed_count}/${item.auto_warmup_event_count} errors ${item.auto_warmup_error_count}`,
|
||
formatDate(item.latest_auto_warmup_at || item.last_heartbeat_at),
|
||
])}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
{fabricLeaseMaintenance && (
|
||
<div className={`noticePanel ${fabricLeaseMaintenance.status === "degraded" ? "warnPanel" : "goodPanel"}`}>
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Service-channel leases</h4>
|
||
<p className="muted">
|
||
Durable compatibility lease records for introspection after backend restarts.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={`pill ${fabricLeaseMaintenance.status === "degraded" ? "warn" : "good"}`}>
|
||
{statusLabel(fabricLeaseMaintenance.status)}
|
||
</span>
|
||
<span className="pill good">active {fabricLeaseMaintenance.active_count}</span>
|
||
<span className={fabricLeaseMaintenance.expired_count > 0 ? "pill warn" : "pill"}>expired {fabricLeaseMaintenance.expired_count}</span>
|
||
<button className="ghost" onClick={() => void cleanupFabricServiceChannelLeases()} disabled={loading}>
|
||
cleanup
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="reason" value={statusLabel(fabricLeaseMaintenance.reason)} />
|
||
<StateLine label="scanned" value={`${fabricLeaseMaintenance.scanned_count}/${fabricLeaseMaintenance.window_limit}`} />
|
||
<StateLine label="deleted" value={String(fabricLeaseMaintenance.deleted_expired_count || 0)} />
|
||
<StateLine label="action" value={fabricLeaseMaintenance.recommended_operator_action || "lease maintenance is current"} />
|
||
</div>
|
||
{(fabricLeaseMaintenance.leases || []).length > 0 && (
|
||
<DataTable
|
||
columns={["expires", "resource", "entry", "exit", "route", "data plane", "state"]}
|
||
rows={(fabricLeaseMaintenance.leases || []).slice(0, 8).map((lease) => [
|
||
formatDate(lease.expires_at),
|
||
lease.resource_id || shortId(lease.channel_id),
|
||
nodeName(nodes, lease.selected_entry_node_id || ""),
|
||
nodeName(nodes, lease.selected_exit_node_id || ""),
|
||
lease.primary_route_id ? `${shortId(lease.primary_route_id)} / ${statusLabel(lease.primary_route_status || "")}` : "backend fallback",
|
||
`${statusLabel(lease.data_plane?.working_data_transport || "unknown")} / ${statusLabel(lease.data_plane?.backend_relay_policy || "unknown")}`,
|
||
<span className={`pill ${lease.expired ? "warn" : lease.force_backend_fallback ? "warn" : "good"}`}>
|
||
{lease.expired ? "expired" : lease.force_backend_fallback ? "fallback" : statusLabel(lease.status)}
|
||
</span>,
|
||
])}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
{fabricAccessTelemetry && (
|
||
<div className={`noticePanel ${fabricAccessTelemetry.status === "degraded" ? "warnPanel" : "goodPanel"}`}>
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Service-channel access</h4>
|
||
<p className="muted">
|
||
Live accepted_by visibility from node telemetry and heartbeat metadata.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={`pill ${fabricAccessTelemetry.status === "degraded" ? "warn" : "good"}`}>
|
||
{statusLabel(fabricAccessTelemetry.status)}
|
||
</span>
|
||
<span className="pill good">accepted {fabricAccessTelemetry.total_accepted}</span>
|
||
<span className={fabricAccessTelemetry.backend_fallback_count > 0 ? "pill warn" : "pill"}>
|
||
backend {fabricAccessTelemetry.backend_fallback_count}
|
||
</span>
|
||
<span className={(fabricAccessTelemetry.backend_fallback_blocked_count || 0) > 0 ? "pill bad" : "pill"}>
|
||
blocked {fabricAccessTelemetry.backend_fallback_blocked_count || 0}
|
||
</span>
|
||
<span className={`pill ${fabricAccessTelemetry.last_working_data_transport === "fabric_service_channel" ? "good" : fabricAccessTelemetry.data_plane_contract_count ? "warn" : ""}`}>
|
||
data-plane {fabricAccessTelemetry.data_plane_contract_count || 0}
|
||
</span>
|
||
<span className={`pill ${fabricAccessTelemetry.last_backend_relay_policy === "disabled" ? "good" : fabricAccessTelemetry.last_backend_relay_policy === "degraded_fallback_only" ? "info" : ""}`}>
|
||
relay {statusLabel(fabricAccessTelemetry.last_backend_relay_policy || "unknown")}
|
||
</span>
|
||
<span className={fabricAccessTelemetry.degraded_fallback_channel_count > 0 || fabricAccessTelemetry.degraded_route_count > 0 ? "pill warn" : "pill good"}>
|
||
channels {fabricAccessTelemetry.active_channel_count}
|
||
</span>
|
||
<span className={fabricAccessTelemetry.no_safe_recovery_decision_count ? "pill warn" : fabricAccessTelemetry.route_decision_channel_count ? "pill info" : "pill"}>
|
||
decisions {fabricAccessTelemetry.route_decision_channel_count || 0}
|
||
{fabricAccessTelemetry.replacement_decision_count ? ` / repl ${fabricAccessTelemetry.replacement_decision_count}` : ""}
|
||
{fabricAccessTelemetry.applied_rebuild_decision_count ? ` / applied ${fabricAccessTelemetry.applied_rebuild_decision_count}` : ""}
|
||
{fabricAccessTelemetry.recovery_decision_count ? ` / recovery ${fabricAccessTelemetry.recovery_decision_count}` : ""}
|
||
{fabricAccessTelemetry.no_safe_recovery_decision_count ? ` / no-safe ${fabricAccessTelemetry.no_safe_recovery_decision_count}` : ""}
|
||
</span>
|
||
<span className={`pill ${flowHealthPillClass(fabricAccessTelemetry.flow_health_status, fabricAccessTelemetry.flow_dropped)}`}>
|
||
{formatTrafficClassCounts(fabricAccessTelemetry.traffic_class_counts)}
|
||
</span>
|
||
<span className={`pill ${flowHealthPillClass(fabricAccessTelemetry.flow_health_status, fabricAccessTelemetry.flow_dropped)}`}>
|
||
flow {statusLabel(fabricAccessTelemetry.flow_health_status || "healthy")}
|
||
</span>
|
||
<span className={`pill ${fabricAccessTelemetry.adaptive_backpressure_active ? "info" : "good"}`}>
|
||
windows {formatRecommendedWindows(fabricAccessTelemetry.recommended_parallel_windows)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="reason" value={statusLabel(fabricAccessTelemetry.reason)} />
|
||
<StateLine label="reporting nodes" value={`${fabricAccessTelemetry.reporting_node_count}/${fabricAccessTelemetry.node_count}`} />
|
||
<StateLine label="accepted by" value={`signed ${fabricAccessTelemetry.signed_accepted}, introspection ${fabricAccessTelemetry.introspection_accepted}, legacy ${fabricAccessTelemetry.legacy_unsigned_accepted}`} />
|
||
<StateLine label="data plane" value={`${fabricAccessTelemetry.data_plane_contract_count || 0} contracts, mode ${statusLabel(fabricAccessTelemetry.last_data_plane_mode || "unknown")}, working ${statusLabel(fabricAccessTelemetry.last_working_data_transport || "unknown")}, steady ${statusLabel(fabricAccessTelemetry.last_steady_state_transport || "unknown")}, relay ${statusLabel(fabricAccessTelemetry.last_backend_relay_policy || "unknown")}, flows ${statusLabel(fabricAccessTelemetry.last_logical_flow_mode || "unknown")}, blocked ${fabricAccessTelemetry.backend_fallback_blocked_count || 0}, route failures ${fabricAccessTelemetry.fabric_route_send_failure_count || 0}`} />
|
||
<StateLine label="data-plane violation" value={fabricAccessTelemetry.last_data_plane_violation_status ? `${statusLabel(fabricAccessTelemetry.last_data_plane_violation_status)} / ${fabricAccessTelemetry.last_data_plane_violation_reason || "n/a"}` : "none"} />
|
||
<StateLine label="active channels" value={`${fabricAccessTelemetry.active_channel_count || 0}, fallback ${fabricAccessTelemetry.degraded_fallback_channel_count || 0}, correlated routes ${fabricAccessTelemetry.correlated_route_count || 0}, degraded routes ${fabricAccessTelemetry.degraded_route_count || 0}`} />
|
||
<StateLine label="route decisions" value={`channels ${fabricAccessTelemetry.route_decision_channel_count || 0}, replacement ${fabricAccessTelemetry.replacement_decision_count || 0}, applied ${fabricAccessTelemetry.applied_rebuild_decision_count || 0}, recovery ${fabricAccessTelemetry.recovery_decision_count || 0}, no-safe ${fabricAccessTelemetry.no_safe_recovery_decision_count || 0}`} />
|
||
<StateLine label="flow QoS" value={`${statusLabel(fabricAccessTelemetry.flow_health_status || "healthy")} / ${statusLabel(fabricAccessTelemetry.flow_health_reason || "flow_health_ready")}, ${formatTrafficClassCounts(fabricAccessTelemetry.traffic_class_counts)}, flows ${fabricAccessTelemetry.flow_channel_count || 0}, in-flight ${fabricAccessTelemetry.flow_max_in_flight || 0}, dropped ${fabricAccessTelemetry.flow_dropped || 0}`} />
|
||
<StateLine label="adaptive windows" value={`${fabricAccessTelemetry.adaptive_backpressure_active ? statusLabel(fabricAccessTelemetry.adaptive_backpressure_reason || "adaptive") : "off"}, ${formatRecommendedWindows(fabricAccessTelemetry.recommended_parallel_windows)}, policy ${fabricAccessTelemetry.adaptive_policy_fingerprint ? shortId(fabricAccessTelemetry.adaptive_policy_fingerprint) : "n/a"}`} />
|
||
<StateLine label="latest accepted" value={formatDate(fabricAccessTelemetry.latest_accepted_at)} />
|
||
<StateLine label="action" value={fabricAccessTelemetry.recommended_operator_action || "access telemetry is current"} />
|
||
</div>
|
||
{(fabricAccessTelemetry.active_channels || []).length > 0 && (
|
||
<DataTable
|
||
columns={["resource", "entry -> exit", "route", "decision", "entry access", "data plane", "flow health", "windows", "flow QoS", "route quality", "remediation", "guard", "execution", "expires"]}
|
||
rows={(fabricAccessTelemetry.active_channels || []).slice(0, 10).map((item) => [
|
||
item.resource_id || shortId(item.channel_id),
|
||
`${nodeName(nodes, item.selected_entry_node_id || "")} -> ${nodeName(nodes, item.selected_exit_node_id || "")}`,
|
||
item.primary_route_id ? `${shortId(item.primary_route_id)} / ${statusLabel(item.primary_route_status || "")}` : "backend fallback",
|
||
<span className={`pill ${routeDecisionTone(item.route_decision_source, item.route_decision_rebuild_status, item.route_decision_score_reasons)}`}>
|
||
{item.route_decision_source
|
||
? `${statusLabel(item.route_decision_source)}${item.route_decision_route_id ? ` ${shortId(item.route_decision_route_id)}` : ""}${item.route_decision_replacement_route_id ? ` -> ${shortId(item.route_decision_replacement_route_id)}` : ""}${item.route_decision_rebuild_status ? ` / ${statusLabel(item.route_decision_rebuild_status)}` : ""}`
|
||
: "n/a"}
|
||
</span>,
|
||
`accepted ${item.entry_node_total_accepted}, introspection ${item.entry_node_introspection_accepted}, backend ${item.entry_node_backend_fallback_count}`,
|
||
<span className={`pill ${item.entry_node_last_working_data_transport === "fabric_service_channel" ? "good" : item.entry_node_data_plane_contract_count ? "warn" : ""}`}>
|
||
{`${item.entry_node_data_plane_contract_count || 0} / ${statusLabel(item.entry_node_last_working_data_transport || "unknown")} / ${statusLabel(item.entry_node_last_backend_relay_policy || "unknown")}${item.entry_node_backend_fallback_blocked_count ? ` / blocked ${item.entry_node_backend_fallback_blocked_count}` : ""}`}
|
||
</span>,
|
||
<span className={`pill ${flowHealthPillClass(item.entry_node_flow_health_status, item.entry_node_flow_dropped)}`}>
|
||
{statusLabel(item.entry_node_flow_health_status || "healthy")}
|
||
{item.entry_node_flow_health_reason ? ` / ${statusLabel(item.entry_node_flow_health_reason)}` : ""}
|
||
</span>,
|
||
<span className={`pill ${item.entry_node_adaptive_backpressure_active ? "info" : "good"}`}>
|
||
{formatRecommendedWindows(item.entry_node_recommended_parallel_windows)}
|
||
</span>,
|
||
<span className={`pill ${flowHealthPillClass(item.entry_node_flow_health_status, item.entry_node_flow_dropped)}`}>
|
||
{formatTrafficClassCounts(item.entry_node_traffic_class_counts)}
|
||
{` / flows ${item.entry_node_flow_channel_count || 0} / in ${item.entry_node_flow_max_in_flight || 0}`}
|
||
</span>,
|
||
<span className={`pill ${item.force_backend_fallback || item.route_feedback_status === "degraded" || item.route_feedback_status === "fenced" ? "warn" : item.route_feedback_status ? "good" : ""}`}>
|
||
{item.force_backend_fallback
|
||
? "backend fallback"
|
||
: item.route_feedback_status
|
||
? `${statusLabel(item.route_feedback_status)} / ${item.last_send_duration_ms || 0}ms / q ${item.route_quality_window_sample_count || 0}`
|
||
: "no route feedback"}
|
||
</span>,
|
||
<span className={`pill ${item.remediation_action === "none" ? "good" : item.remediation_action === "prefer_alternate_route" ? "warn" : item.remediation_action ? "bad" : ""}`}>
|
||
{item.remediation_action
|
||
? `${item.remediation_command ? "cmd " : ""}${statusLabel(item.remediation_action)}${item.remediation_command?.replacement_route_id ? ` -> ${shortId(item.remediation_command.replacement_route_id)}` : item.remediation_route_id ? ` -> ${shortId(item.remediation_route_id)}` : ""}`
|
||
: "n/a"}
|
||
</span>,
|
||
<span className={`pill ${item.remediation_guard_status === "rejected" ? "bad" : item.pool_policy_fingerprint ? "good" : ""}`}>
|
||
{item.remediation_guard_status ? statusLabel(item.remediation_guard_status) : item.pool_policy_fingerprint ? "pool policy" : "n/a"}
|
||
{item.remediation_guard_reason ? ` / ${statusLabel(item.remediation_guard_reason)}` : ""}
|
||
</span>,
|
||
<span className={`pill ${remediationExecutionTone(item.remediation_execution_status)}`}>
|
||
{item.remediation_execution_status ? statusLabel(item.remediation_execution_status) : "n/a"}
|
||
{item.remediation_execution_generation ? ` / ${shortId(item.remediation_execution_generation)}` : ""}
|
||
{item.remediation_execution_reason ? ` / ${statusLabel(item.remediation_execution_reason)}` : ""}
|
||
</span>,
|
||
formatDate(item.expires_at),
|
||
])}
|
||
/>
|
||
)}
|
||
{(fabricAccessTelemetry.nodes || []).length > 0 && (
|
||
<DataTable
|
||
columns={["node", "accepted", "signed", "introspection", "legacy", "backend", "data plane", "flow health", "windows", "flow QoS", "latest"]}
|
||
rows={(fabricAccessTelemetry.nodes || []).slice(0, 10).map((item) => [
|
||
nodeName(nodes, item.node_id) || item.node_name || shortId(item.node_id),
|
||
item.total_accepted,
|
||
item.signed_accepted,
|
||
item.introspection_accepted,
|
||
item.legacy_unsigned_accepted,
|
||
<span className={item.backend_fallback_count > 0 ? "pill warn" : "pill"}>
|
||
{item.backend_fallback_count}
|
||
</span>,
|
||
<span className={`pill ${item.last_working_data_transport === "fabric_service_channel" ? "good" : item.data_plane_contract_count ? "warn" : ""}`}>
|
||
{`${item.data_plane_contract_count || 0} / ${statusLabel(item.last_working_data_transport || "unknown")} / ${statusLabel(item.last_backend_relay_policy || "unknown")}${item.backend_fallback_blocked_count ? ` / blocked ${item.backend_fallback_blocked_count}` : ""}`}
|
||
</span>,
|
||
<span className={`pill ${flowHealthPillClass(item.flow_health_status, item.flow_dropped)}`}>
|
||
{statusLabel(item.flow_health_status || "healthy")}
|
||
{item.flow_health_reason ? ` / ${statusLabel(item.flow_health_reason)}` : ""}
|
||
</span>,
|
||
<span className={`pill ${item.adaptive_backpressure_active ? "info" : "good"}`}>
|
||
{formatRecommendedWindows(item.recommended_parallel_windows)}
|
||
</span>,
|
||
<span className={`pill ${flowHealthPillClass(item.flow_health_status, item.flow_dropped)}`}>
|
||
{formatTrafficClassCounts(item.traffic_class_counts)}
|
||
{` / flows ${item.flow_channel_count || 0} / in ${item.flow_max_in_flight || 0}`}
|
||
</span>,
|
||
formatDate(item.last_accepted_at || item.observed_at),
|
||
])}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
{fabricReadiness && (
|
||
<div className={`noticePanel ${fabricReadiness.status === "blocked" ? "badPanel" : fabricReadiness.status === "degraded" ? "warnPanel" : "goodPanel"}`}>
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Fabric service-channel readiness</h4>
|
||
<p className="muted">
|
||
Verdict for production service-channel use. Working service payloads should not depend on this fabric while the gate is blocked.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={`pill ${fabricReadiness.status === "blocked" ? "bad" : fabricReadiness.status === "degraded" ? "warn" : "good"}`}>
|
||
{statusLabel(fabricReadiness.status)}
|
||
</span>
|
||
<span className={fabricReadiness.active_alert_count > 0 ? "pill bad" : "pill"}>active {fabricReadiness.active_alert_count}</span>
|
||
<span className={fabricReadiness.resurfaced_count > 0 ? "pill bad" : "pill"}>resurfaced {fabricReadiness.resurfaced_count}</span>
|
||
</div>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="reason" value={statusLabel(fabricReadiness.reason)} />
|
||
<StateLine label="blocking" value={(fabricReadiness.blocking_reasons || []).map(statusLabel).join(", ") || "нет"} />
|
||
<StateLine label="degraded" value={(fabricReadiness.degraded_reasons || []).map(statusLabel).join(", ") || "нет"} />
|
||
<StateLine label="missing/post" value={`transition ${fabricReadiness.missing_transition_count}, route-gen ${fabricReadiness.missing_route_generation_count}, traffic ${fabricReadiness.missing_post_rebuild_traffic_count}`} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{fabricRebuildIncidents.length > 0 && (
|
||
<div className="subPanel">
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Rebuild incidents</h4>
|
||
<p className="muted">
|
||
Grouped recent rebuild attempts by reporter, route, service, generation, and guard. Open an incident to load the exact deep ledger slice.
|
||
</p>
|
||
</div>
|
||
<span className="pill">{fabricRebuildIncidents.length}</span>
|
||
</div>
|
||
<DataTable
|
||
columns={["last", "source", "reporter", "route", "service", "guard", "count", "replacement", "action"]}
|
||
rows={fabricRebuildIncidents.slice(0, 10).map((incident) => [
|
||
formatDate(incident.last_seen_at),
|
||
incident.incident_source ? statusLabel(incident.incident_source) : "ledger",
|
||
nodeName(nodes, incident.reporter_node_id),
|
||
shortId(incident.route_id),
|
||
incident.service_class,
|
||
<span className={`pill ${incident.alert_resurfaced ? "bad" : incident.guard_severity === "bad" ? "bad" : incident.guard_severity === "warn" ? "warn" : "good"}`}>
|
||
{statusLabel(incident.guard_status)}{incident.alert_silenced ? " / silenced" : incident.alert_resurfaced ? " / resurfaced" : ""}
|
||
</span>,
|
||
String(incident.attempt_count),
|
||
incident.latest_replacement_route_id ? shortId(incident.latest_replacement_route_id) : "нет",
|
||
<div className="inlineActions">
|
||
<span>
|
||
{statusLabel(incident.recommended_operator_action || "inspect")}
|
||
{incident.alert_resurfaced && incident.alert_resurfaced_cause
|
||
? ` (${statusLabel(incident.alert_resurfaced_cause)})`
|
||
: ""}
|
||
{incident.alert_resurfaced && incident.alert_resurfaced_previous_generation
|
||
? ` from ${shortId(incident.alert_resurfaced_previous_generation)} until ${formatDate(incident.alert_resurfaced_previous_until)}`
|
||
: ""}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void runAction(() => openFabricRebuildIncidentDeepLedger(incident), "Deep rebuild investigation opened.")}
|
||
>
|
||
open deep
|
||
</button>
|
||
{!incident.alert_silenced ? (
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void runAction(() => silenceFabricRebuildIncident(incident), "Rebuild incident silenced for 6 hours.")}
|
||
>
|
||
silence 6h
|
||
</button>
|
||
) : (
|
||
<span className="muted">silenced</span>
|
||
)}
|
||
</div>,
|
||
])}
|
||
/>
|
||
</div>
|
||
)}
|
||
{(fabricBreadcrumbWindowPolicy || fabricDrilldownAuditEvents.length > 0) && (
|
||
<div className="subPanel">
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Recent investigations</h4>
|
||
<p className="muted">
|
||
Recent operator drilldowns opened from rebuild incidents or feedback breakdown rows.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className="pill info">{fabricDrilldownAuditSummary?.total_count || fabricDrilldownAuditEvents.length}</span>
|
||
<span className="pill good">linked {fabricDrilldownAuditSummary?.correlated_count || 0}</span>
|
||
<span className={(fabricDrilldownAuditSummary?.not_visible_count || 0) > 0 ? "pill warn" : "pill"}>
|
||
not visible {fabricDrilldownAuditSummary?.not_visible_count || 0}
|
||
</span>
|
||
{Object.entries(fabricDrilldownAuditSummary?.counts_by_breadcrumb_status || {}).map(([status, count]) => (
|
||
<span key={status} className={status === "current" ? "pill good" : status === "stale" ? "pill warn" : "pill bad"}>
|
||
{statusLabel(status)} {count}
|
||
</span>
|
||
))}
|
||
{Object.entries(fabricDrilldownAuditSummary?.counts_by_current_diagnostic_status || {}).slice(0, 3).map(([status, count]) => (
|
||
<span key={status} className={status === "breakdown_active" || status === "incident_visible" ? "pill good" : status === "not_visible" ? "pill warn" : "pill"}>
|
||
{statusLabel(status)} {count}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{fabricBreadcrumbWindowPolicy && (
|
||
<div className="inlineForm">
|
||
<label>
|
||
current window, sec
|
||
<input
|
||
type="number"
|
||
min="60"
|
||
value={fabricBreadcrumbWindowPolicyForm.currentWindowSeconds}
|
||
onChange={(event) => setFabricBreadcrumbWindowPolicyForm((form) => ({ ...form, currentWindowSeconds: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
history window, sec
|
||
<input
|
||
type="number"
|
||
min="60"
|
||
value={fabricBreadcrumbWindowPolicyForm.historyWindowSeconds}
|
||
onChange={(event) => setFabricBreadcrumbWindowPolicyForm((form) => ({ ...form, historyWindowSeconds: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const updated = await client.updateFabricServiceChannelBreadcrumbWindowPolicy(selectedClusterId, {
|
||
currentWindowSeconds: Number(fabricBreadcrumbWindowPolicyForm.currentWindowSeconds),
|
||
historyWindowSeconds: Number(fabricBreadcrumbWindowPolicyForm.historyWindowSeconds),
|
||
});
|
||
setFabricBreadcrumbWindowPolicy(updated);
|
||
const drilldownAudit = await client.getFabricServiceChannelRebuildInvestigationBreadcrumbs(selectedClusterId, { limit: 20 });
|
||
setFabricDrilldownAudit(drilldownAudit.events);
|
||
setFabricDrilldownAuditSummary(drilldownAudit.summary || null);
|
||
}, "Breadcrumb window policy updated.")
|
||
}
|
||
>
|
||
apply windows
|
||
</button>
|
||
<span className="muted">
|
||
source {fabricBreadcrumbWindowPolicy.source}, fp {shortId(fabricBreadcrumbWindowPolicy.fingerprint || "")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{fabricDrilldownAuditSummary && (
|
||
<div className="stateList">
|
||
<StateLine label="latest" value={formatDate(fabricDrilldownAuditSummary.latest_at)} />
|
||
<StateLine
|
||
label="windows"
|
||
value={`${fabricBreadcrumbWindowPolicy?.current_window_seconds || "n/a"}s current / ${fabricBreadcrumbWindowPolicy?.history_window_seconds || "n/a"}s history`}
|
||
/>
|
||
<StateLine
|
||
label="sources"
|
||
value={
|
||
Object.entries(fabricDrilldownAuditSummary.counts_by_feedback_source || {})
|
||
.slice(0, 3)
|
||
.map(([source, count]) => `${statusLabel(source)} ${count}`)
|
||
.join(", ") || "нет"
|
||
}
|
||
/>
|
||
<StateLine
|
||
label="violations"
|
||
value={
|
||
Object.entries(fabricDrilldownAuditSummary.counts_by_feedback_violation_status || {})
|
||
.slice(0, 3)
|
||
.map(([status, count]) => `${statusLabel(status)} ${count}`)
|
||
.join(", ") || "нет"
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
<DataTable
|
||
columns={["time", "freshness", "source", "feedback", "target", "current", "actor", "reason"]}
|
||
rows={fabricDrilldownAuditEvents.map((event) => {
|
||
const payload = objectField(event.payload) || {};
|
||
const feedbackChannelID = stringField(payload, "feedback_channel_id", "");
|
||
const feedbackViolationStatus = stringField(payload, "feedback_violation_status", "");
|
||
const feedbackSource = stringField(payload, "feedback_source", "");
|
||
const reporterNodeID = stringField(payload, "reporter_node_id", "");
|
||
const routeID = stringField(payload, "route_id", "");
|
||
const drilldownSource = stringField(payload, "drilldown_source", "");
|
||
const hintStatus = event.correlation_hints?.current_diagnostic_status || "";
|
||
const breadcrumbStatus = event.correlation_hints?.breadcrumb_status || "current";
|
||
const breadcrumbAgeSeconds = event.correlation_hints?.breadcrumb_age_seconds;
|
||
const matchingBreakdown = event.correlation_hints?.feedback_breakdown || fabricFeedbackBreakdownForAuditEvent(event);
|
||
const matchingIncident = event.correlation_hints?.rebuild_incident || fabricRebuildIncidentForAuditEvent(event);
|
||
return [
|
||
formatDate(event.created_at),
|
||
<div className="stackedText">
|
||
<span className={breadcrumbStatus === "current" ? "pill good" : breadcrumbStatus === "stale" ? "pill warn" : "pill bad"}>
|
||
{statusLabel(breadcrumbStatus)}
|
||
</span>
|
||
<span className="muted">{formatAgeSeconds(breadcrumbAgeSeconds)}</span>
|
||
</div>,
|
||
<div className="stackedText">
|
||
<span>{event.event_type.includes("feedback_breakdown") ? "feedback breakdown" : "incident"}</span>
|
||
<span className="muted">{statusLabel(drilldownSource || event.target_type)}</span>
|
||
</div>,
|
||
feedbackSource || feedbackChannelID || feedbackViolationStatus ? (
|
||
<div className="stackedText">
|
||
<span>{statusLabel(feedbackSource || "feedback")}</span>
|
||
<span className="muted">{feedbackChannelID ? `ch ${shortId(feedbackChannelID)}` : "any channel"}</span>
|
||
<span className="muted">{statusLabel(feedbackViolationStatus || "any violation")}</span>
|
||
</div>
|
||
) : (
|
||
<span className="muted">нет</span>
|
||
),
|
||
<div className="stackedText">
|
||
<span>{reporterNodeID ? nodeName(nodes, reporterNodeID) : "any reporter"}</span>
|
||
<span className="muted">{routeID ? shortId(routeID) : event.target_id ? shortId(event.target_id) : "any route"}</span>
|
||
</div>,
|
||
matchingBreakdown ? (
|
||
<div className="inlineActions">
|
||
<span className={matchingBreakdown.active_bad_count ? "pill bad" : matchingBreakdown.active_warn_count ? "pill warn" : "pill good"}>
|
||
{statusLabel(hintStatus || "breakdown_active")}
|
||
</span>
|
||
<span className="muted">
|
||
bad {matchingBreakdown.active_bad_count || 0} / warn {matchingBreakdown.active_warn_count || 0}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void runAction(() => openFabricRebuildFeedbackBreakdownLedger(matchingBreakdown), "Rebuild ledger opened for current feedback breakdown.")}
|
||
>
|
||
open
|
||
</button>
|
||
</div>
|
||
) : matchingIncident ? (
|
||
<div className="inlineActions">
|
||
<span className={`pill ${matchingIncident.guard_severity === "bad" ? "bad" : matchingIncident.guard_severity === "warn" ? "warn" : "good"}`}>
|
||
{statusLabel(hintStatus || "incident_visible")}
|
||
</span>
|
||
<span className="muted">{statusLabel(matchingIncident.guard_status)}</span>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void runAction(() => openFabricRebuildIncidentDeepLedger(matchingIncident), "Deep rebuild investigation opened for current incident.")}
|
||
>
|
||
open
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<span className="muted">{statusLabel(hintStatus || "not_visible")}</span>
|
||
),
|
||
event.actor_user_id ? shortId(event.actor_user_id) : "system",
|
||
stringField(payload, "reason", "operator opened investigation"),
|
||
];
|
||
})}
|
||
/>
|
||
</div>
|
||
)}
|
||
{fabricRebuildSilences.length > 0 && (
|
||
<div className="subPanel">
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Active rebuild silences</h4>
|
||
<p className="muted">
|
||
Operator acknowledgements currently suppressing rebuild/access-decision alerts. Remove a silence to let the incident become active again.
|
||
</p>
|
||
</div>
|
||
<span className="pill info">{fabricRebuildSilences.length}</span>
|
||
</div>
|
||
<DataTable
|
||
columns={["until", "source", "channel", "reporter", "route", "guard", "reason", "action"]}
|
||
rows={fabricRebuildSilences.slice(0, 10).map((silence) => [
|
||
formatDate(silence.expires_at),
|
||
silence.incident_source ? statusLabel(silence.incident_source) : "ledger",
|
||
silence.channel_id ? shortId(silence.channel_id) : "нет",
|
||
nodeName(nodes, silence.reporter_node_id),
|
||
shortId(silence.display_route_id || silence.route_id),
|
||
statusLabel(silence.guard_status),
|
||
silence.reason || "acknowledged",
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void runAction(() => unsilenceFabricRebuildAlert(silence), "Rebuild alert silence removed.")}
|
||
>
|
||
unsilence
|
||
</button>,
|
||
])}
|
||
/>
|
||
</div>
|
||
)}
|
||
{fabricRebuildHealth && (
|
||
<div className="subPanel">
|
||
<div className="cardHead compact">
|
||
<div>
|
||
<h4>Rebuild health</h4>
|
||
<p className="muted">
|
||
Сводка по последним {fabricRebuildHealth.total_attempts} rebuild попыткам. Данные помогают быстро увидеть, где backend уже принял решение, но node-agent или post-rebuild traffic не подтвердили результат.
|
||
</p>
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className="pill good">ok {fabricRebuildHealth.good_count}</span>
|
||
<span className={fabricRebuildHealth.active_warn_count > 0 ? "pill warn" : "pill"}>warn {fabricRebuildHealth.active_warn_count}/{fabricRebuildHealth.warn_count}</span>
|
||
<span className={fabricRebuildHealth.active_bad_count > 0 ? "pill bad" : "pill"}>bad {fabricRebuildHealth.active_bad_count}/{fabricRebuildHealth.bad_count}</span>
|
||
<span className={fabricRebuildHealth.resurfaced_count > 0 ? "pill bad" : "pill"}>resurfaced {fabricRebuildHealth.resurfaced_count}</span>
|
||
<span className={fabricRebuildHealth.silenced_count > 0 ? "pill info" : "pill"}>silenced {fabricRebuildHealth.silenced_count}</span>
|
||
<span className="pill">applied {fabricRebuildHealth.applied_count}</span>
|
||
<span className={fabricRebuildHealth.access_no_safe_count ? "pill bad" : fabricRebuildHealth.access_route_decision_count ? "pill info" : "pill"}>
|
||
access {fabricRebuildHealth.access_route_decision_count || 0}
|
||
{fabricRebuildHealth.access_no_safe_count ? ` / no-safe ${fabricRebuildHealth.access_no_safe_count}` : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="observed" value={formatDate(fabricRebuildHealth.observed_at)} />
|
||
<StateLine label="affected nodes" value={(fabricRebuildHealth.affected_reporter_node_ids || []).map((id) => nodeName(nodes, id)).join(", ") || "нет"} />
|
||
<StateLine label="affected routes" value={(fabricRebuildHealth.affected_route_ids || []).map(shortId).join(", ") || "нет"} />
|
||
<StateLine label="action" value={statusLabel(fabricRebuildHealth.recommended_operator_action || "no_operator_action_required")} />
|
||
</div>
|
||
{(fabricRebuildHealth.feedback_breakdowns || []).length > 0 && (
|
||
<DataTable
|
||
columns={["feedback", "active", "total", "affected", "incidents", "latest", "action"]}
|
||
rows={(fabricRebuildHealth.feedback_breakdowns || []).slice(0, 8).map((item) => {
|
||
const relatedIncidents = fabricRebuildIncidentsForFeedbackBreakdown(item);
|
||
return [
|
||
<div className="stackedText">
|
||
<span>{statusLabel(item.feedback_source || "feedback")}</span>
|
||
<span className="muted">{item.feedback_channel_id ? `ch ${shortId(item.feedback_channel_id)}` : "any channel"}</span>
|
||
<span className="muted">{statusLabel(item.feedback_violation_status || "unknown")}</span>
|
||
</div>,
|
||
<span className={item.active_bad_count ? "pill bad" : item.active_warn_count ? "pill warn" : "pill"}>
|
||
bad {item.active_bad_count || 0} / warn {item.active_warn_count || 0}
|
||
</span>,
|
||
`total ${item.total_count} / bad ${item.bad_count || 0} / warn ${item.warn_count || 0} / silenced ${item.silenced_count || 0}`,
|
||
<div className="stackedText">
|
||
<span>{(item.affected_reporter_node_ids || []).map((id) => nodeName(nodes, id)).join(", ") || "нет узлов"}</span>
|
||
<span className="muted">{(item.affected_route_ids || []).map(shortId).join(", ") || "нет routes"}</span>
|
||
</div>,
|
||
relatedIncidents.length > 0 ? (
|
||
<div className="stackedText">
|
||
<span className="pill warn">{relatedIncidents.length}</span>
|
||
<span className="muted">{relatedIncidents.slice(0, 2).map((incident) => statusLabel(incident.guard_status)).join(", ")}</span>
|
||
</div>
|
||
) : (
|
||
<span className="muted">нет</span>
|
||
),
|
||
formatDate(item.latest_observed_at),
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void runAction(() => openFabricRebuildFeedbackBreakdownLedger(item), "Rebuild ledger opened for feedback breakdown.")}
|
||
>
|
||
open ledger
|
||
</button>,
|
||
];
|
||
})}
|
||
/>
|
||
)}
|
||
{(fabricRebuildHealth.most_recent_bad_attempts || []).length > 0 && (
|
||
<DataTable
|
||
columns={["time", "reporter", "route", "guard", "reason"]}
|
||
rows={(fabricRebuildHealth.most_recent_bad_attempts || []).slice(0, 5).map((attempt) => [
|
||
formatDate(attempt.updated_at),
|
||
nodeName(nodes, attempt.reporter_node_id),
|
||
shortId(attempt.route_id),
|
||
<span className="pill bad">{statusLabel(attempt.guard_status || "bad")}</span>,
|
||
<div className="inlineActions">
|
||
<span>{statusLabel(attempt.guard_reason || "unknown")}</span>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.silenceFabricServiceChannelRouteRebuildAlert(selectedClusterId, {
|
||
reporterNodeId: attempt.reporter_node_id,
|
||
routeId: attempt.route_id,
|
||
guardStatus: attempt.guard_status || "unknown",
|
||
generation: attempt.generation || "",
|
||
reason: "operator acknowledged known rebuild alert",
|
||
ttlSeconds: 21600,
|
||
}),
|
||
"Rebuild alert silenced for this route generation.",
|
||
)
|
||
}
|
||
>
|
||
silence 6h
|
||
</button>
|
||
</div>,
|
||
])}
|
||
/>
|
||
)}
|
||
{(fabricRebuildHealth.resurfaced_attempts || []).length > 0 && (
|
||
<DataTable
|
||
columns={["time", "reporter", "route", "guard", "previous", "action"]}
|
||
rows={(fabricRebuildHealth.resurfaced_attempts || []).slice(0, 5).map((attempt) => [
|
||
formatDate(attempt.updated_at),
|
||
nodeName(nodes, attempt.reporter_node_id),
|
||
shortId(attempt.route_id),
|
||
<span className="pill bad">{statusLabel(attempt.guard_status || "bad")}</span>,
|
||
`${shortId(attempt.alert_resurfaced_previous_generation)} until ${formatDate(attempt.alert_resurfaced_previous_until)}`,
|
||
<div className="inlineActions">
|
||
<span>{statusLabel(attempt.guard_reason || "unknown")}</span>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.silenceFabricServiceChannelRouteRebuildAlert(selectedClusterId, {
|
||
reporterNodeId: attempt.reporter_node_id,
|
||
routeId: attempt.route_id,
|
||
guardStatus: attempt.guard_status || "unknown",
|
||
generation: attempt.generation || "",
|
||
reason: "operator acknowledged resurfaced rebuild alert",
|
||
ttlSeconds: 21600,
|
||
}),
|
||
"Resurfaced rebuild alert silenced for this generation.",
|
||
)
|
||
}
|
||
>
|
||
silence 6h
|
||
</button>
|
||
</div>,
|
||
])}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
{fabricRecoveryPolicy && (
|
||
<div className="inlineForm">
|
||
<label>
|
||
penalty
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={fabricRecoveryPolicyForm.hysteresisPenalty}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, hysteresisPenalty: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
promote samples
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={fabricRecoveryPolicyForm.promotionMinSamples}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, promotionMinSamples: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
fail
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={fabricRecoveryPolicyForm.demotionFailureThreshold}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, demotionFailureThreshold: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
drop
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={fabricRecoveryPolicyForm.demotionDropThreshold}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, demotionDropThreshold: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
slow
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={fabricRecoveryPolicyForm.demotionSlowThreshold}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, demotionSlowThreshold: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={fabricRecoveryPolicyForm.demotionRebuildEnabled}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, demotionRebuildEnabled: event.target.checked }))}
|
||
/>
|
||
rebuild
|
||
</label>
|
||
<label className="checkLine">
|
||
<input
|
||
type="checkbox"
|
||
checked={fabricRecoveryPolicyForm.demotionFencedEnabled}
|
||
onChange={(event) => setFabricRecoveryPolicyForm((form) => ({ ...form, demotionFencedEnabled: event.target.checked }))}
|
||
/>
|
||
fenced
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const updated = await client.updateFabricServiceChannelRecoveryPolicy(selectedClusterId, {
|
||
hysteresisPenalty: Number(fabricRecoveryPolicyForm.hysteresisPenalty),
|
||
promotionMinSamples: Number(fabricRecoveryPolicyForm.promotionMinSamples),
|
||
demotionFailureThreshold: Number(fabricRecoveryPolicyForm.demotionFailureThreshold),
|
||
demotionDropThreshold: Number(fabricRecoveryPolicyForm.demotionDropThreshold),
|
||
demotionSlowThreshold: Number(fabricRecoveryPolicyForm.demotionSlowThreshold),
|
||
demotionRebuildEnabled: fabricRecoveryPolicyForm.demotionRebuildEnabled,
|
||
demotionFencedEnabled: fabricRecoveryPolicyForm.demotionFencedEnabled,
|
||
});
|
||
setFabricRecoveryPolicy(updated);
|
||
}, "Recovery policy updated.")
|
||
}
|
||
>
|
||
apply policy
|
||
</button>
|
||
<span className="muted">source {fabricRecoveryPolicy.source}</span>
|
||
</div>
|
||
)}
|
||
<DataTable
|
||
columns={["route", "reporter", "service", "status", "recovery", "score", "reasons", "failures", "retry/cooldown", "expires", "action"]}
|
||
rows={fabricRouteFeedbackVisible.slice(0, 80).map((item) => [
|
||
shortId(item.route_id),
|
||
nodeName(nodes, item.reporter_node_id),
|
||
item.service_class,
|
||
<span className={`pill ${serviceChannelFeedbackTone(item.feedback_status)}`}>{statusLabel(item.feedback_status)}</span>,
|
||
item.recovery_state ? (
|
||
<span className={`pill ${serviceChannelRecoveryTone(item.recovery_state)}`}>
|
||
{item.recovery_demoted ? `demoted ${item.recovery_reason ? statusLabel(item.recovery_reason) : ""}` : item.recovery_promoted ? "promoted" : statusLabel(item.recovery_state)}
|
||
{item.recovery_hysteresis_penalty ? ` -${item.recovery_hysteresis_penalty}` : ""}
|
||
</span>
|
||
) : item.stale_policy || item.stale_generation ? (
|
||
<span className="pill warn">{statusLabel(item.stale_reason || "stale")}</span>
|
||
) : item.provenance_missing ? (
|
||
<span className="pill warn">provenance missing</span>
|
||
) : (
|
||
"нет"
|
||
),
|
||
String(item.score_adjustment),
|
||
(item.reasons || []).join(", ") || "нет",
|
||
String(item.consecutive_failures || 0),
|
||
item.retry_cooldown_until ? formatDate(item.retry_cooldown_until) : "нет",
|
||
formatDate(item.expires_at),
|
||
item.feedback_status === "healthy" || item.feedback_status === "operator_retry_cooldown" ? (
|
||
<span className="muted">нет</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() =>
|
||
void runAction(
|
||
() =>
|
||
client.expireFabricServiceChannelRouteFeedback(selectedClusterId, {
|
||
routeId: item.route_id,
|
||
reporterNodeId: item.reporter_node_id,
|
||
serviceClass: item.service_class,
|
||
reason: "operator expired stale service-channel feedback",
|
||
}),
|
||
"Service-channel feedback expired.",
|
||
)
|
||
}
|
||
>
|
||
expire
|
||
</button>
|
||
),
|
||
])}
|
||
/>
|
||
{fabricRouteFeedbackVisible.length === 0 && <EmptyState title="Feedback отсутствует" text="Нет свежих route feedback наблюдений от fabric service-channel runtime." />}
|
||
<DataTable
|
||
columns={["local node", "route", "replacement", "rebuild", "attempt", "feedback", "source", "destination", "decision", "score", "expires"]}
|
||
rows={[...fabricReplacementDecisions, ...fabricNoAlternateDecisions, ...fabricRebuildDecisions]
|
||
.filter((decision, index, all) => all.findIndex((item) => item.decision_id === decision.decision_id) === index)
|
||
.slice(0, 80)
|
||
.map((decision) => [
|
||
nodeName(nodes, decision.local_node_id),
|
||
shortId(decision.route_id),
|
||
decision.replacement_route_id ? shortId(decision.replacement_route_id) : "нет",
|
||
decision.rebuild_status || "нет",
|
||
decision.rebuild_attempt == null ? "н/д" : String(decision.rebuild_attempt),
|
||
decision.feedback_observation_id ? (
|
||
<div className="stackedText">
|
||
<span>{statusLabel(decision.feedback_source || "feedback")}</span>
|
||
<span className="muted">{shortId(decision.feedback_observation_id)} {decision.feedback_channel_id ? `ch ${shortId(decision.feedback_channel_id)}` : ""}</span>
|
||
<span className="muted">{statusLabel(decision.feedback_violation_status || "")}</span>
|
||
</div>
|
||
) : "нет",
|
||
nodeName(nodes, decision.source_node_id),
|
||
nodeName(nodes, decision.destination_node_id),
|
||
decision.decision_source,
|
||
decision.path_score == null ? "н/д" : String(decision.path_score),
|
||
formatDate(decision.expires_at),
|
||
])}
|
||
/>
|
||
<div className="inlineForm">
|
||
<label>
|
||
reporter
|
||
<select
|
||
value={fabricRebuildLedgerFilters.reporterNodeId}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, reporterNodeId: event.target.value, offset: 0 }))}
|
||
>
|
||
<option value="">all</option>
|
||
{nodes.map((node) => (
|
||
<option key={node.id} value={node.id}>{node.name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
route
|
||
<input
|
||
value={fabricRebuildLedgerFilters.routeId}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, routeId: event.target.value.trim(), offset: 0 }))}
|
||
placeholder="route id"
|
||
/>
|
||
</label>
|
||
<label>
|
||
generation
|
||
<input
|
||
value={fabricRebuildLedgerFilters.generation}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, generation: event.target.value.trim(), offset: 0 }))}
|
||
placeholder="route generation"
|
||
/>
|
||
</label>
|
||
<label>
|
||
service
|
||
<input
|
||
value={fabricRebuildLedgerFilters.serviceClass}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, serviceClass: event.target.value.trim(), offset: 0 }))}
|
||
placeholder="vpn_packets"
|
||
/>
|
||
</label>
|
||
<label>
|
||
feedback source
|
||
<input
|
||
value={fabricRebuildLedgerFilters.feedbackSource}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, feedbackSource: event.target.value.trim(), offset: 0 }))}
|
||
placeholder="fabric_service_channel_access_report"
|
||
/>
|
||
</label>
|
||
<label>
|
||
channel
|
||
<input
|
||
value={fabricRebuildLedgerFilters.feedbackChannelId}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, feedbackChannelId: event.target.value.trim(), offset: 0 }))}
|
||
placeholder="feedback channel id"
|
||
/>
|
||
</label>
|
||
<label>
|
||
violation
|
||
<input
|
||
value={fabricRebuildLedgerFilters.feedbackViolationStatus}
|
||
onChange={(event) => setFabricRebuildLedgerFilters((filters) => ({ ...filters, feedbackViolationStatus: event.target.value.trim(), offset: 0 }))}
|
||
placeholder="fabric_route_send_failed_backend_fallback_blocked"
|
||
/>
|
||
</label>
|
||
<button type="button" className="ghost" onClick={() => void loadFabricRebuildLedger(fabricRebuildLedgerDeep, { ...fabricRebuildLedgerFilters, offset: 0 })}>
|
||
apply
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => {
|
||
const cleared = { ...defaultFabricRebuildLedgerFilters };
|
||
setFabricRebuildLedgerFilters(cleared);
|
||
void loadFabricRebuildLedger(false, cleared);
|
||
}}
|
||
>
|
||
clear
|
||
</button>
|
||
</div>
|
||
<DataTable
|
||
columns={["time", "reporter", "route", "replacement", "feedback", "guard", "outcome", "backend", "agent", "route-gen", "traffic", "policy", "hops"]}
|
||
rows={fabricRebuildAttempts.slice(0, 80).map((attempt) => [
|
||
formatDate(attempt.updated_at),
|
||
nodeName(nodes, attempt.reporter_node_id),
|
||
shortId(attempt.route_id),
|
||
attempt.replacement_route_id ? shortId(attempt.replacement_route_id) : "нет",
|
||
attempt.feedback_observation_id ? (
|
||
<div className="stackedText">
|
||
<span>{statusLabel(attempt.feedback_source || "feedback")}</span>
|
||
<span className="muted">{shortId(attempt.feedback_observation_id)} {attempt.feedback_channel_id ? `ch ${shortId(attempt.feedback_channel_id)}` : ""}</span>
|
||
<span className="muted">{statusLabel(attempt.feedback_violation_status || "")}</span>
|
||
</div>
|
||
) : attempt.feedback_status ? statusLabel(attempt.feedback_status) : "нет",
|
||
fabricRebuildLedgerDeep ? (
|
||
<span className={`pill ${attempt.guard_severity === "bad" ? "bad" : attempt.guard_severity === "warn" ? "warn" : "good"}`}>
|
||
{statusLabel(attempt.guard_status || "unknown")}{attempt.alert_silenced ? " / silenced" : attempt.alert_resurfaced ? " / resurfaced" : ""}
|
||
</span>
|
||
) : (
|
||
<span className="pill info">summary</span>
|
||
),
|
||
statusLabel(attempt.outcome),
|
||
<span className={`pill ${attempt.rebuild_status === "applied" ? "good" : "warn"}`}>{statusLabel(attempt.rebuild_status)}</span>,
|
||
!fabricRebuildLedgerDeep ? (
|
||
<span className="pill info">deep only</span>
|
||
) : attempt.node_transition_matched ? (
|
||
<span className={`pill ${attempt.node_transition_status === "applied_rebuild" ? "good" : "warn"}`}>{statusLabel(attempt.node_transition_status || "matched")}</span>
|
||
) : (
|
||
<span className="pill warn">not seen</span>
|
||
),
|
||
!fabricRebuildLedgerDeep ? (
|
||
<span className="pill info">deep only</span>
|
||
) : attempt.node_route_generation_matched ? (
|
||
<span className="pill good">{statusLabel(attempt.node_route_generation_status || "seen")}</span>
|
||
) : (
|
||
<span className="pill warn">not seen</span>
|
||
),
|
||
!fabricRebuildLedgerDeep
|
||
? "deep only"
|
||
: attempt.post_rebuild_selected_route_id
|
||
? `${shortId(attempt.post_rebuild_selected_route_id)} packets ${attempt.post_rebuild_send_flow_packets || attempt.post_rebuild_send_packets || 0} drop ${attempt.post_rebuild_send_flow_dropped || 0}`
|
||
: "нет",
|
||
attempt.policy_fingerprint ? shortId(attempt.policy_fingerprint) : "нет",
|
||
`${(attempt.old_hops || []).map((id) => nodeName(nodes, id)).join(" -> ") || "нет"} => ${(attempt.replacement_hops || []).map((id) => nodeName(nodes, id)).join(" -> ") || "нет"}`,
|
||
])}
|
||
/>
|
||
<div className="inlineActions">
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => void loadFabricRebuildLedger(!fabricRebuildLedgerDeep, { ...fabricRebuildLedgerFilters, offset: 0 })}
|
||
>
|
||
{fabricRebuildLedgerDeep ? "fast ledger" : "deep ledger"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
disabled={!fabricRebuildLedgerDeep || fabricRebuildLedgerFilters.offset <= 0}
|
||
onClick={() =>
|
||
void loadFabricRebuildLedger(true, {
|
||
...fabricRebuildLedgerFilters,
|
||
offset: Math.max(0, fabricRebuildLedgerFilters.offset - 20),
|
||
})
|
||
}
|
||
>
|
||
prev
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
disabled={!fabricRebuildLedgerDeep || fabricRebuildAttempts.length < 20}
|
||
onClick={() => void loadFabricRebuildLedger(true, { ...fabricRebuildLedgerFilters, offset: fabricRebuildLedgerFilters.offset + 20 })}
|
||
>
|
||
next
|
||
</button>
|
||
<span className="pill">offset {fabricRebuildLedgerDeep ? fabricRebuildLedgerFilters.offset : 0}</span>
|
||
<span className="muted">Deep ledger correlates heartbeat timeline and can be slower; default refresh stays fast.</span>
|
||
</div>
|
||
{fabricRebuildAttempts.length === 0 && <EmptyState title="Rebuild ledger пуст" text="Пока нет долговечной истории service-channel route rebuild решений." />}
|
||
</article>
|
||
<article className="card span2">
|
||
<h3>{t.servicePlacement}</h3>
|
||
<DataTable
|
||
columns={["узел", "runtime", "адрес", "здоровье", "роли", "желаемые / reported сервисы", "последний heartbeat"]}
|
||
rows={nodes.map((node) => {
|
||
const runtime = nodeRuntimeConnectivity(node, heartbeatsByNode[node.id] || [], meshLinks);
|
||
return [
|
||
node.name,
|
||
<RuntimeBadges runtime={runtime} />,
|
||
runtime.address,
|
||
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={latestLinkObservations(meshLinks).filter((link) => link.source_node_id !== link.target_node_id).map((link) => {
|
||
const sourceNode = nodes.find((node) => node.id === link.source_node_id);
|
||
const targetNode = nodes.find((node) => node.id === link.target_node_id);
|
||
return [
|
||
<NodeLinkEndpoint node={sourceNode} fallback={nodeName(nodes, link.source_node_id)} heartbeatsByNode={heartbeatsByNode} meshLinks={meshLinks} />,
|
||
<NodeLinkEndpoint node={targetNode} fallback={nodeName(nodes, link.target_node_id)} heartbeatsByNode={heartbeatsByNode} meshLinks={meshLinks} />,
|
||
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 состояние, gateway packet stats и диагностика Android-клиента.</p>
|
||
</div>
|
||
<div className="actions compactActions">
|
||
<button
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const expired = await client.expireStaleVPNLeases(selectedClusterId);
|
||
setNotice(`Истекшие VPN lease: ${expired.length}.`);
|
||
}, "Stale VPN lease проверены.")
|
||
}
|
||
>
|
||
Проверить stale lease
|
||
</button>
|
||
<button onClick={() => void refreshVPNClientDiagnostic()}>Обновить клиент</button>
|
||
</div>
|
||
</div>
|
||
<div className="inlineForm">
|
||
<label>
|
||
Android device id
|
||
<input
|
||
value={vpnDiagnosticDeviceId}
|
||
placeholder="0315f630-..."
|
||
onChange={(event) => setVPNDiagnosticDeviceId(event.target.value)}
|
||
onBlur={() => localStorage.setItem(storageKeys.vpnDiagnosticDeviceId, vpnDiagnosticDeviceId.trim())}
|
||
/>
|
||
</label>
|
||
{vpnClientDiagnostics.length > 0 && (
|
||
<label>
|
||
Найденные клиенты
|
||
<select
|
||
value={vpnDiagnosticDeviceId}
|
||
onChange={(event) => {
|
||
const deviceId = event.target.value;
|
||
setVPNDiagnosticDeviceId(deviceId);
|
||
localStorage.setItem(storageKeys.vpnDiagnosticDeviceId, deviceId);
|
||
setVPNClientDiagnostic(vpnClientDiagnostics.find((item) => item.device_id === deviceId) || null);
|
||
}}
|
||
>
|
||
{vpnClientDiagnostics.map((item) => {
|
||
const payload = objectField(item.payload) || {};
|
||
return (
|
||
<option key={item.device_id} value={item.device_id}>
|
||
{shortId(item.device_id)} / {stringField(payload, "app_version", "н/д")} / {formatDate(item.observed_at)}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="diagnosticCommandPanel">
|
||
<label>
|
||
URL для теста
|
||
<input value={vpnDiagnosticTestUrl} onChange={(event) => setVPNDiagnosticTestUrl(event.target.value)} />
|
||
</label>
|
||
<div className="actions compactActions">
|
||
<button onClick={() => void sendVPNDiagnosticCommand({ type: "refresh_profile" }, "Профиль")}>Обновить профиль</button>
|
||
<button onClick={() => void sendVPNDiagnosticCommand({ type: "start_vpn" }, "VPN")}>Старт VPN</button>
|
||
<button onClick={() => void sendVPNDiagnosticCommand({ type: "stop_vpn" }, "VPN")}>Стоп VPN</button>
|
||
<button onClick={() => void sendVPNDiagnosticCommand({ type: "vpn_stats" }, "Stats")}>Stats</button>
|
||
<button onClick={() => void sendVPNDiagnosticCommand({ type: "vpn_http_get", url: vpnDiagnosticTestUrl }, "VPN HTTP")}>VPN HTTP</button>
|
||
<button onClick={() => void sendVPNDiagnosticCommand({ type: "open_url", url: vpnDiagnosticTestUrl }, "Открыть URL")}>Открыть URL</button>
|
||
<button
|
||
className="primary"
|
||
onClick={() =>
|
||
void sendVPNDiagnosticCommand({ type: "full_vpn_test", url: vpnDiagnosticTestUrl, watch_seconds: 45 }, "Полный VPN test")
|
||
}
|
||
>
|
||
Полный тест
|
||
</button>
|
||
</div>
|
||
{lastVPNDiagnosticCommand && (
|
||
<p className="muted">
|
||
Последняя команда: {stringField(lastVPNDiagnosticCommand.payload, "type", "н/д")} / {formatDate(lastVPNDiagnosticCommand.created_at)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{renderVPNClientDiagnostic(vpnClientDiagnostic)}
|
||
<div className="stack">
|
||
{vpnConnections.map((connection) => {
|
||
const runtime = objectField(connection.metadata?.client_config);
|
||
const fabricRoute = objectField(runtime?.vpn_fabric_route);
|
||
const entryPool = arrayStrings(fabricRoute?.entry_pool_node_ids || connection.placement_policy?.entry_node_ids);
|
||
const exitPool = arrayStrings(fabricRoute?.exit_pool_node_ids || connection.placement_policy?.exit_node_ids);
|
||
const selectedEntryNodeId = String(fabricRoute?.selected_entry_node_id || entryPool[0] || "");
|
||
const selectedExitNodeId = String(
|
||
fabricRoute?.selected_exit_node_id ||
|
||
vpnLeases[connection.id]?.owner_node_id ||
|
||
connection.placement_policy?.exit_node_id ||
|
||
exitPool[0] ||
|
||
"",
|
||
);
|
||
const stats = vpnPacketStats[connection.id] || {};
|
||
return (
|
||
<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} />
|
||
<span className={`pill ${runtime?.packet_forwarding ? "good" : "warn"}`}>
|
||
{runtime?.packet_forwarding ? "gateway packet relay active" : "gateway packet relay inactive"}
|
||
</span>
|
||
<span className="pill">
|
||
{String(fabricRoute?.preferred_data_plane || "backend_relay")} / fallback {String(fabricRoute?.fallback_data_plane || "н/д")}
|
||
</span>
|
||
</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="Fabric route"
|
||
value={`${selectedEntryNodeId ? nodeName(nodes, selectedEntryNodeId) : "entry auto"} -> ${selectedExitNodeId ? nodeName(nodes, selectedExitNodeId) : "exit auto"}`}
|
||
/>
|
||
<StateLine label="Entry pool" value={entryPool.map((id) => nodeName(nodes, id)).join(", ") || "н/д"} />
|
||
<StateLine label="Exit pool" value={exitPool.map((id) => nodeName(nodes, id)).join(", ") || "н/д"} />
|
||
<StateLine label="Runtime" value={String(runtime?.runtime_status || "н/д")} />
|
||
<StateLine label="Gateway" value={String(runtime?.gateway_assignment_status || "н/д")} />
|
||
<StateLine label="Client -> gateway" value={vpnPacketStatLine(stats.client_to_gateway)} />
|
||
<StateLine label="Gateway -> client" value={vpnPacketStatLine(stats.gateway_to_client)} />
|
||
<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="grid two">
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Организации и пользователи</h3>
|
||
<p className="muted">Операционный слой для владельца платформы: tenant scope, роли участников и безопасная сводка без раскрытия core mesh.</p>
|
||
</div>
|
||
<span className="pill">{organizations.length}</span>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
Slug
|
||
<input value={organizationForm.slug} onChange={(event) => setOrganizationForm({ ...organizationForm, slug: event.target.value })} placeholder="home" />
|
||
</label>
|
||
<label>
|
||
Название
|
||
<input value={organizationForm.name} onChange={(event) => setOrganizationForm({ ...organizationForm, name: event.target.value })} placeholder="HOME" />
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
disabled={!organizationForm.slug.trim() || !organizationForm.name.trim()}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const org = await client.createOrganization(organizationForm);
|
||
setOrganizationForm({ slug: "", name: "" });
|
||
setOrganizationId(org.id);
|
||
setMembershipForm((previous) => ({ ...previous, organizationId: org.id }));
|
||
setResourceForm((previous) => ({ ...previous, organizationId: org.id }));
|
||
}, "Организация создана.")
|
||
}
|
||
>
|
||
Создать организацию
|
||
</button>
|
||
</div>
|
||
<DataTable
|
||
columns={["организация", "slug", "статус", "ресурсы", "участники", "действие"]}
|
||
rows={organizations.map((org) => {
|
||
const orgResources = resources.filter((resource) => resource.organization_id === org.id);
|
||
const memberships = membershipsByOrg[org.id] || [];
|
||
return [
|
||
org.name,
|
||
org.slug,
|
||
<Status value={org.status} />,
|
||
String(orgResources.length),
|
||
String(memberships.length),
|
||
<div className="actions" key={org.id}>
|
||
<button
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
setOrganizationId(org.id);
|
||
setOrganizationSummary(await client.getOrganizationAdminSummary(org.id));
|
||
}, "Сводка организации загружена.")
|
||
}
|
||
>
|
||
Открыть
|
||
</button>
|
||
</div>,
|
||
];
|
||
})}
|
||
/>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Пользователь</h3>
|
||
<FormGrid>
|
||
<label>
|
||
Email / логин
|
||
<input value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} placeholder="user@example.com" />
|
||
</label>
|
||
<label>
|
||
Пароль
|
||
<input
|
||
type="password"
|
||
value={userForm.password}
|
||
onChange={(event) => setUserForm({ ...userForm, password: event.target.value })}
|
||
placeholder="минимум 8 символов"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Роль платформы
|
||
<select value={userForm.platformRole} onChange={(event) => setUserForm({ ...userForm, platformRole: event.target.value })}>
|
||
<option value="user">user</option>
|
||
<option value="platform_admin">platform_admin</option>
|
||
<option value="platform_recovery_admin">platform_recovery_admin</option>
|
||
</select>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
disabled={!userForm.email.trim() || userForm.password.length < 8}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const user = await client.createUser(userForm);
|
||
setUsers(await client.listUsers());
|
||
setUserForm({ email: "", password: "", platformRole: "user" });
|
||
setMembershipForm((previous) => ({ ...previous, userId: user.id }));
|
||
}, "Пользователь создан.")
|
||
}
|
||
>
|
||
Создать пользователя
|
||
</button>
|
||
</div>
|
||
<DataTable
|
||
columns={["пользователь", "роль платформы", "id"]}
|
||
rows={users.map((user) => [user.email, <Status value={user.platform_role || "user"} />, <code>{user.id}</code>])}
|
||
/>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Участник организации</h3>
|
||
<FormGrid>
|
||
<label>
|
||
Организация
|
||
<select value={membershipForm.organizationId} onChange={(event) => setMembershipForm({ ...membershipForm, organizationId: event.target.value })}>
|
||
<option value="">Выберите организацию</option>
|
||
{organizations.map((org) => (
|
||
<option key={org.id} value={org.id}>
|
||
{org.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Пользователь
|
||
<select value={membershipForm.userId} onChange={(event) => setMembershipForm({ ...membershipForm, userId: event.target.value })}>
|
||
<option value="">Выберите пользователя</option>
|
||
{users.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.email}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Роль
|
||
<select value={membershipForm.roleId} onChange={(event) => setMembershipForm({ ...membershipForm, roleId: event.target.value })}>
|
||
<option value="org_owner">org_owner</option>
|
||
<option value="org_admin">org_admin</option>
|
||
<option value="org_operator">org_operator</option>
|
||
<option value="org_member">org_member</option>
|
||
<option value="org_viewer">org_viewer</option>
|
||
</select>
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
disabled={!membershipForm.organizationId || !membershipForm.userId.trim()}
|
||
onClick={() =>
|
||
void runAction(
|
||
() => client.addOrganizationMembership(membershipForm.organizationId, { userId: membershipForm.userId, roleId: membershipForm.roleId }),
|
||
"Участник организации сохранен.",
|
||
)
|
||
}
|
||
>
|
||
Сохранить участника
|
||
</button>
|
||
</div>
|
||
</article>
|
||
|
||
<article className="card">
|
||
<h3>Безопасная сводка</h3>
|
||
<div className="inlineForm">
|
||
<select value={organizationId} onChange={(event) => setOrganizationId(event.target.value)}>
|
||
<option value="">Выберите организацию</option>
|
||
{organizations.map((org) => (
|
||
<option key={org.id} value={org.id}>
|
||
{org.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
disabled={!organizationId}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
setOrganizationSummary(await client.getOrganizationAdminSummary(organizationId));
|
||
}, "Сводка организации загружена.")
|
||
}
|
||
>
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
{organizationSummary ? (
|
||
<div className="stack">
|
||
<Metric label="Ресурсы" value={organizationSummary.resource_count} tone="steel" />
|
||
<Metric label="Активные сессии" value={organizationSummary.active_session_count} tone="green" />
|
||
<StateLine label="Topology exposure" value={organizationSummary.topology_exposure} />
|
||
<DataTable
|
||
columns={["контур", "состояние"]}
|
||
rows={Object.entries(organizationSummary.connector_status || {}).map(([key, value]) => [
|
||
key,
|
||
typeof value === "string" ? statusLabel(value) : JSON.stringify(value),
|
||
])}
|
||
/>
|
||
<DataTable columns={["протокол", "количество"]} rows={organizationSummary.service_endpoints.map((item) => [item.protocol, String(item.count)])} />
|
||
</div>
|
||
) : (
|
||
<EmptyState title="Сводка не выбрана" text="Выберите организацию, чтобы проверить tenant-safe состояние." />
|
||
)}
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{activeView === "servers" && (
|
||
<section className="grid two">
|
||
<article className="card span2">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3>Справочник серверов</h3>
|
||
<p className="muted">Единый каталог целей для RDP/VPN: адрес сервера, организация, протокол и предпочтительный вход/выход маршрута.</p>
|
||
</div>
|
||
<span className="pill">{resources.length}</span>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
Организация
|
||
<select value={resourceForm.organizationId} onChange={(event) => setResourceForm({ ...resourceForm, organizationId: event.target.value })}>
|
||
<option value="">Выберите организацию</option>
|
||
{organizations.map((org) => (
|
||
<option key={org.id} value={org.id}>
|
||
{org.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Имя сервера
|
||
<input value={resourceForm.name} onChange={(event) => setResourceForm({ ...resourceForm, name: event.target.value })} placeholder="Office RDP" />
|
||
</label>
|
||
<label>
|
||
Адрес
|
||
<input value={resourceForm.address} onChange={(event) => setResourceForm({ ...resourceForm, address: event.target.value })} placeholder="192.168.1.10:3389" />
|
||
</label>
|
||
<label>
|
||
Протокол
|
||
<select value={resourceForm.protocol} onChange={(event) => setResourceForm({ ...resourceForm, protocol: event.target.value })}>
|
||
<option value="rdp">RDP</option>
|
||
<option value="vpn">VPN</option>
|
||
<option value="ssh">SSH</option>
|
||
<option value="http">HTTP</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Вход
|
||
<select value={resourceForm.entryNode} onChange={(event) => setResourceForm({ ...resourceForm, entryNode: event.target.value })}>
|
||
<option value="">Автоматически</option>
|
||
{nodes.map((node) => (
|
||
<option key={node.id} value={node.id}>
|
||
{node.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Выход
|
||
<select value={resourceForm.exitNode} onChange={(event) => setResourceForm({ ...resourceForm, exitNode: event.target.value })}>
|
||
<option value="">Автоматически</option>
|
||
{nodes.map((node) => (
|
||
<option key={node.id} value={node.id}>
|
||
{node.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Теги
|
||
<input value={resourceForm.tags} onChange={(event) => setResourceForm({ ...resourceForm, tags: event.target.value })} placeholder="home, accounting" />
|
||
</label>
|
||
<label>
|
||
RDP пользователь
|
||
<input value={resourceForm.username} onChange={(event) => setResourceForm({ ...resourceForm, username: event.target.value })} placeholder="user или DOMAIN\\user" />
|
||
</label>
|
||
<label>
|
||
RDP пароль
|
||
<input type="password" value={resourceForm.password} onChange={(event) => setResourceForm({ ...resourceForm, password: event.target.value })} placeholder="хранится как secret" />
|
||
</label>
|
||
<label>
|
||
Домен
|
||
<input value={resourceForm.domain} onChange={(event) => setResourceForm({ ...resourceForm, domain: event.target.value })} placeholder="опционально" />
|
||
</label>
|
||
</FormGrid>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
disabled={!resourceForm.organizationId || !resourceForm.name.trim() || !resourceForm.address.trim()}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
const secretRef =
|
||
["rdp", "vnc", "ssh"].includes(resourceForm.protocol)
|
||
? `rap-secret://org/${resourceForm.organizationId}/resources/${crypto.randomUUID()}/primary`
|
||
: null;
|
||
const created = await client.createResource({
|
||
organizationId: resourceForm.organizationId,
|
||
name: resourceForm.name,
|
||
address: resourceForm.address,
|
||
protocol: resourceForm.protocol,
|
||
secretRef,
|
||
certificateVerificationMode: resourceForm.protocol === "rdp" ? "ignore" : "strict",
|
||
clipboardMode: resourceForm.protocol === "rdp" ? "bidirectional" : "disabled",
|
||
fileTransferMode: resourceForm.protocol === "rdp" ? "bidirectional" : "disabled",
|
||
metadata: {
|
||
route_mode: resourceForm.routeMode,
|
||
preferred_entry_node_id: resourceForm.entryNode || null,
|
||
preferred_exit_node_id: resourceForm.exitNode || null,
|
||
tags: resourceForm.tags.split(",").map((item) => item.trim()).filter(Boolean),
|
||
},
|
||
});
|
||
if (["rdp", "vnc", "ssh"].includes(resourceForm.protocol) && (resourceForm.username.trim() || resourceForm.password)) {
|
||
await client.upsertResourceSecret(created.id, {
|
||
username: resourceForm.username.trim(),
|
||
password: resourceForm.password,
|
||
domain: resourceForm.domain.trim(),
|
||
});
|
||
}
|
||
setResourceForm({ ...resourceForm, name: "", address: "", tags: "", username: "", password: "", domain: "" });
|
||
}, "Сервер добавлен в справочник.")
|
||
}
|
||
>
|
||
Добавить сервер
|
||
</button>
|
||
</div>
|
||
<DataTable
|
||
columns={["сервер", "адрес", "протокол", "секрет", "организация", "маршрут", "создано", "действия"]}
|
||
rows={resources.map((resource) => {
|
||
const metadata = resource.metadata || {};
|
||
const org = organizations.find((item) => item.id === resource.organization_id);
|
||
return [
|
||
resource.name,
|
||
resource.address,
|
||
resource.protocol,
|
||
resource.has_secret ? "сохранен" : resource.secret_ref ? "нужен payload" : "нет",
|
||
org?.name || shortId(resource.organization_id),
|
||
`${shortId(String(metadata.preferred_entry_node_id || "")) || "auto"} -> ${shortId(String(metadata.preferred_exit_node_id || "")) || "auto"}`,
|
||
formatDate(resource.created_at),
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setResourceSecretDialog(resource);
|
||
setResourceSecretForm({ username: "", password: "", domain: "" });
|
||
}}
|
||
>
|
||
Обновить secret
|
||
</button>,
|
||
];
|
||
})}
|
||
/>
|
||
{resourceSecretDialog && (
|
||
<div className="modalBackdrop" role="presentation">
|
||
<div className="modalCard" role="dialog" aria-modal="true" aria-labelledby="resource-secret-title">
|
||
<div className="cardHead">
|
||
<div>
|
||
<h3 id="resource-secret-title">Учетные данные RDP</h3>
|
||
<p className="muted">
|
||
{resourceSecretDialog.name} · {resourceSecretDialog.address}
|
||
</p>
|
||
</div>
|
||
<button className="ghost" onClick={() => setResourceSecretDialog(null)}>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
<FormGrid>
|
||
<label>
|
||
Пользователь
|
||
<input value={resourceSecretForm.username} onChange={(event) => setResourceSecretForm({ ...resourceSecretForm, username: event.target.value })} placeholder="user или DOMAIN\\user" />
|
||
</label>
|
||
<label>
|
||
Пароль
|
||
<input type="password" value={resourceSecretForm.password} onChange={(event) => setResourceSecretForm({ ...resourceSecretForm, password: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
Домен
|
||
<input value={resourceSecretForm.domain} onChange={(event) => setResourceSecretForm({ ...resourceSecretForm, domain: event.target.value })} placeholder="опционально" />
|
||
</label>
|
||
</FormGrid>
|
||
<p className="muted">Пароль сохраняется как encrypted resource secret. В metadata ресурса он не попадет.</p>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
disabled={!resourceSecretForm.username.trim() || !resourceSecretForm.password}
|
||
onClick={() =>
|
||
void runAction(async () => {
|
||
await client.upsertResourceSecret(resourceSecretDialog.id, {
|
||
username: resourceSecretForm.username.trim(),
|
||
password: resourceSecretForm.password,
|
||
domain: resourceSecretForm.domain.trim(),
|
||
});
|
||
setResourceSecretDialog(null);
|
||
setResourceSecretForm({ username: "", password: "", domain: "" });
|
||
}, "Secret ресурса обновлен.")
|
||
}
|
||
>
|
||
Сохранить secret
|
||
</button>
|
||
<button onClick={() => setResourceSecretDialog(null)}>Отмена</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
</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: ReactNode }) {
|
||
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 FunctionState({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||
return (
|
||
<span className={`functionState ${tone || ""}`}>
|
||
<small>{label}</small>
|
||
<strong>{value}</strong>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function StateLine({ label, value }: { label: string; value: ReactNode }) {
|
||
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 renderVPNClientDiagnostic(status: VPNClientDiagnosticStatus | null) {
|
||
if (!status) {
|
||
return <p className="muted">Диагностика Android-клиента не загружена. Укажи device id из приложения и нажми “Обновить клиент”.</p>;
|
||
}
|
||
const payload = objectField(status.payload) || {};
|
||
const runtime = objectField(payload.runtime);
|
||
const config = objectField(payload.vpn_config);
|
||
const appVersion = stringField(payload, "app_version", "н/д");
|
||
const serviceState = stringField(payload, "service_state", "н/д");
|
||
const controlMode = stringField(payload, "control_network_mode", "н/д");
|
||
const activeRelay = stringField(config, "packet_relay_active_base_url") || stringField(config, "packet_relay_base_url", "н/д");
|
||
const profileRelay = stringField(config, "packet_relay_profile_base_url", "н/д");
|
||
const candidates = stringField(config, "packet_relay_candidate_urls", "н/д");
|
||
const read = numberField(runtime, "uplink_read_total");
|
||
const sent = numberField(runtime, "uplink_sent_total");
|
||
const down = numberField(runtime, "downlink_received_total");
|
||
const drops = numberField(runtime, "uplink_dropped_packets") + numberField(runtime, "downlink_dropped_packets");
|
||
const bypass = numberField(runtime, "uplink_bypassed_control_packets");
|
||
const downBytes = numberField(runtime, "downlink_received_bytes");
|
||
const upBytes = numberField(runtime, "uplink_sent_bytes");
|
||
const runtimeState = stringField(runtime, "state", "н/д");
|
||
const runtimeMessage = stringField(runtime, "message", "");
|
||
const upMbps = numberField(runtime, "uplink_sent_mbps");
|
||
const downMbps = numberField(runtime, "downlink_received_mbps");
|
||
const lastCommandType = stringField(payload, "last_command_type", "н/д");
|
||
const lastCommandResult = stringField(payload, "last_command_result", "н/д");
|
||
return (
|
||
<div className="vpnCard diagnosticCard">
|
||
<div>
|
||
<strong>Android client {shortId(status.device_id)}</strong>
|
||
<p className="muted">
|
||
{appVersion} / {serviceState} / {formatDate(status.observed_at)}
|
||
</p>
|
||
<Status value={Date.now() - new Date(status.observed_at).getTime() < 30_000 ? "active" : "degraded"} />
|
||
<span className="pill">{controlMode}</span>
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="Relay active" value={activeRelay} />
|
||
<StateLine label="Relay profile" value={profileRelay} />
|
||
<StateLine label="Relay candidates" value={candidates} />
|
||
<StateLine label="Packets read/sent/down" value={`${read} / ${sent} / ${down}`} />
|
||
<StateLine label="Drops / control bypass" value={`${drops} / ${bypass}`} />
|
||
<StateLine label="Bytes up/down" value={`${formatBytes(upBytes)} / ${formatBytes(downBytes)}`} />
|
||
<StateLine label="Rate up/down" value={`${upMbps.toFixed(2)} / ${downMbps.toFixed(2)} Mbps`} />
|
||
<StateLine label="Runtime" value={runtimeMessage ? `${runtimeState}: ${runtimeMessage}` : runtimeState} />
|
||
<StateLine label="Last command" value={`${lastCommandType}: ${lastCommandResult}`} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 NodeDetailsDashboard({
|
||
node,
|
||
memberships,
|
||
activeRoles,
|
||
desiredWorkloads,
|
||
observedWorkloads,
|
||
heartbeats,
|
||
telemetry,
|
||
meshLinks,
|
||
syntheticConfig,
|
||
allNodes,
|
||
onSetUpdatePolicy,
|
||
updatePlan,
|
||
updateStatuses,
|
||
labels,
|
||
}: {
|
||
node: ClusterNode;
|
||
memberships: Array<{ cluster: Cluster; node: ClusterNode }>;
|
||
activeRoles: RoleAssignment[];
|
||
desiredWorkloads: NodeWorkloadDesiredState[];
|
||
observedWorkloads: WorkloadStatus[];
|
||
heartbeats: NodeHeartbeat[];
|
||
telemetry: NodeTelemetryObservation[];
|
||
meshLinks: MeshLink[];
|
||
syntheticConfig?: NodeSyntheticMeshConfig;
|
||
allNodes: ClusterNode[];
|
||
onSetUpdatePolicy?: (node: ClusterNode, product: string, targetVersion: string | null) => void;
|
||
updatePlan?: NodeUpdatePlan;
|
||
updateStatuses: NodeUpdateStatus[];
|
||
labels: Record<string, string>;
|
||
}) {
|
||
const latestHeartbeat = heartbeats[0];
|
||
const latestTelemetry = telemetry[0];
|
||
const listenerReport = objectField(latestHeartbeat?.metadata?.mesh_listener_report);
|
||
const endpointReport = objectField(latestHeartbeat?.metadata?.mesh_endpoint_report);
|
||
const outboundReport = objectField(latestHeartbeat?.metadata?.mesh_outbound_session_report);
|
||
const desiredListenerConfig = syntheticConfig?.mesh_listener;
|
||
const recoveryReport = objectField(latestHeartbeat?.metadata?.mesh_peer_recovery_report);
|
||
const intentReport = objectField(latestHeartbeat?.metadata?.mesh_peer_connection_intent_report);
|
||
const managerReport = objectField(latestHeartbeat?.metadata?.mesh_peer_connection_manager_report);
|
||
const rendezvousReport = objectField(latestHeartbeat?.metadata?.mesh_rendezvous_lease_report);
|
||
const routeDecisionReport = objectField(latestHeartbeat?.metadata?.mesh_route_path_decision_report);
|
||
const routeGenerationReport = objectField(latestHeartbeat?.metadata?.mesh_route_generation_report);
|
||
const routeHealthReport = objectField(latestHeartbeat?.metadata?.mesh_route_health_config_report);
|
||
const serviceChannelFeedback = syntheticConfig?.service_channel_route_feedback;
|
||
const serviceChannelFeedbackItems = serviceChannelFeedback?.observations || [];
|
||
const serviceChannelRemediationCommands = syntheticConfig?.service_channel_remediation_commands || [];
|
||
const latestLinks = latestLinkObservations(meshLinks).filter((link) => link.source_node_id !== link.target_node_id);
|
||
const reachableLinks = latestLinks.filter((link) => link.link_status === "reachable");
|
||
const failedLinks = latestLinks.filter((link) => link.link_status !== "reachable");
|
||
const capabilityEntries = Object.entries(latestHeartbeat?.capabilities || {}).sort(([left], [right]) => left.localeCompare(right));
|
||
const managerProbeResults = arrayObjects(managerReport?.probe_results);
|
||
const [activeTab, setActiveTab] = useState("network");
|
||
const latestNodeAgentUpdate = latestUpdateStatus(updateStatuses, "rap-node-agent");
|
||
const latestHostAgentUpdate = latestUpdateStatus(updateStatuses, "rap-host-agent");
|
||
const latestAnyUpdate = updateStatuses[0];
|
||
const updaterHealth = nodeUpdaterSummary(updateStatuses);
|
||
const primaryClusterID = memberships.find((membership) => membership.node.id === node.id)?.cluster.id || memberships[0]?.cluster.id || "";
|
||
const reportedCandidates = arrayObjects(endpointReport?.endpoint_candidates);
|
||
const primaryReportedCandidate = reportedCandidates[0];
|
||
const advertisedEndpoint =
|
||
firstStringField(endpointReport, ["peer_endpoint", "advertised_endpoint", "endpoint"]) ||
|
||
stringField(primaryReportedCandidate, "address", "") ||
|
||
"";
|
||
const reportedTransport =
|
||
firstStringField(endpointReport, ["transport", "advertise_transport"]) ||
|
||
stringField(primaryReportedCandidate, "transport", "") ||
|
||
"н/д";
|
||
const reportedConnectivity =
|
||
firstStringField(endpointReport, ["connectivity_mode", "connectivity"]) ||
|
||
stringField(primaryReportedCandidate, "connectivity_mode", "") ||
|
||
stringField(listenerReport, "inbound_reachability", "") ||
|
||
"н/д";
|
||
const reportedNat = stringField(endpointReport, "nat_type", stringField(primaryReportedCandidate, "nat_type", "н/д"));
|
||
const reportedRegion = stringField(endpointReport, "region", stringField(listenerReport, "region", stringField(primaryReportedCandidate, "region", "н/д")));
|
||
const reportedObservedAt = stringField(endpointReport, "observed_at", stringField(listenerReport, "observed_at", latestHeartbeat?.observed_at || "н/д"));
|
||
const listenerStatus =
|
||
stringField(listenerReport, "status", "") ||
|
||
(advertisedEndpoint ? "нет listener report, есть advertised endpoint" : "report отсутствует");
|
||
const listenerEffectiveAddr =
|
||
stringField(listenerReport, "effective_listen_addr", "") ||
|
||
"н/д";
|
||
const listenerConfiguredAddr =
|
||
stringField(listenerReport, "configured_listen_addr", "") ||
|
||
"н/д";
|
||
const listenerCandidates =
|
||
reportedCandidates.length > 0
|
||
? reportedCandidates
|
||
: advertisedEndpoint
|
||
? [
|
||
{
|
||
endpoint_id: `${node.id}-reported`,
|
||
address: advertisedEndpoint,
|
||
transport: reportedTransport,
|
||
reachability: reportedConnectivity,
|
||
connectivity_mode: reportedConnectivity,
|
||
nat_type: reportedNat,
|
||
priority: "н/д",
|
||
last_verified_at: reportedObservedAt,
|
||
},
|
||
]
|
||
: [];
|
||
const configuredPeerEndpoints = Object.entries(syntheticConfig?.peer_endpoints || {});
|
||
const configuredPeerCandidates = Object.entries(syntheticConfig?.peer_endpoint_candidates || {}).flatMap(([peerID, candidates]) =>
|
||
candidates.map((candidate) => ({ peerID, candidate })),
|
||
);
|
||
const activePeerIDs = new Set(reachableLinks.map((link) => (link.source_node_id === node.id ? link.target_node_id : link.source_node_id)));
|
||
const reservePeerCandidates = configuredPeerCandidates.filter(({ peerID }) => !activePeerIDs.has(peerID));
|
||
const networkDiagnostics = [
|
||
listenerReport ? "listener report: есть" : "listener report: не прислан агентом",
|
||
endpointReport ? "endpoint report: есть" : "endpoint report: не прислан агентом",
|
||
outboundReport ? "outbound session: есть" : "outbound session: не прислан агентом",
|
||
syntheticConfig ? `scoped config: ${syntheticConfig.enabled ? "enabled" : "disabled"}` : "scoped config: не загружен",
|
||
serviceChannelFeedback ? `service-channel feedback: ${serviceChannelFeedback.observation_count}` : "service-channel feedback: не загружен",
|
||
`active links: ${reachableLinks.length}/${latestLinks.length}`,
|
||
];
|
||
const tabs = [
|
||
["overview", "Обзор"],
|
||
["network", "Сеть и адреса"],
|
||
["mesh", "Mesh"],
|
||
["services", "Роли и сервисы"],
|
||
["telemetry", "Телеметрия"],
|
||
["updates", "Обновления"],
|
||
["raw", "Raw"],
|
||
] as const;
|
||
|
||
return (
|
||
<div className="nodeDetails">
|
||
<section className="nodePanel">
|
||
<h4>Сводка runtime</h4>
|
||
<div className="signalStrip compact nodeMetricGrid">
|
||
<Signal label="Heartbeat" value={latestHeartbeat ? formatDate(latestHeartbeat.observed_at) : "н/д"} />
|
||
<Signal label="Health" value={statusLabel(latestHeartbeat?.health_status || node.health_status)} />
|
||
<Signal label="Listener" value={meshListenerSummary(latestHeartbeat)} />
|
||
<Signal label="Mesh links" value={`${reachableLinks.length}/${latestLinks.length}`} />
|
||
<Signal label="Update" value={updateStatusSummary(latestAnyUpdate, updatePlan)} />
|
||
</div>
|
||
<div className="summaryChips">
|
||
<Status value={node.registration_status} />
|
||
<Status value={node.membership_status} />
|
||
<Status value={node.partition_state} />
|
||
<span className="pill">{node.reported_version || latestHeartbeat?.reported_version || "версия неизвестна"}</span>
|
||
{listenerReport?.one_way_connectivity === true && <span className="pill warn">one-way</span>}
|
||
{listenerReport?.port_conflict === true && <span className="pill bad">port conflict</span>}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="nodeTabs" role="tablist" aria-label="Node analysis sheets">
|
||
{tabs.map(([id, label]) => (
|
||
<button key={id} className={activeTab === id ? "active" : ""} onClick={() => setActiveTab(id)} type="button">
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{activeTab === "overview" && (
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>Идентичность и размещение</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Node ID" value={node.id} />
|
||
<StateLine label="Node key" value={node.node_key} />
|
||
<StateLine label="Имя" value={node.name} />
|
||
<StateLine label="Владение" value={statusLabel(node.ownership_type)} />
|
||
<StateLine label="Owner org" value={shortId(node.owner_organization_id)} />
|
||
<StateLine label="Группа" value={node.node_group_name || labels.ungroupedNodes} />
|
||
<StateLine label="Создан" value={formatDate(node.created_at)} />
|
||
<StateLine label="Обновлен" value={formatDate(node.updated_at)} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Участие в кластерах</h4>
|
||
<div className="membershipList">
|
||
{memberships.map((membership) => (
|
||
<span key={membership.cluster.id} className={membership.node.id === node.id && membership.node.membership_status === "active" ? "pill good" : "pill"}>
|
||
{membership.cluster.name}: {statusLabel(membership.node.membership_status)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="stateList">
|
||
<StateLine label="Активных ролей" value={String(activeRoles.length)} />
|
||
<StateLine label="Desired workloads" value={String(desiredWorkloads.length)} />
|
||
<StateLine label="Observed workloads" value={String(observedWorkloads.length)} />
|
||
<StateLine label="Последний сигнал" value={formatDate(node.last_seen_at || latestHeartbeat?.observed_at)} />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "network" && (
|
||
<>
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>Локальный listener</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Статус" value={listenerStatus} />
|
||
<StateLine label="Режим порта" value={stringField(listenerReport, "listen_port_mode", "н/д")} />
|
||
<StateLine label="Configured addr" value={listenerConfiguredAddr} />
|
||
<StateLine label="Effective addr" value={listenerEffectiveAddr} />
|
||
<StateLine label="Inbound" value={stringField(listenerReport, "inbound_reachability", reportedConnectivity)} />
|
||
<StateLine label="One-way" value={stringField(listenerReport, "one_way_connectivity", "н/д")} />
|
||
<StateLine label="Port conflict" value={stringField(listenerReport, "port_conflict", "false")} />
|
||
<StateLine label="Failure" value={stringField(listenerReport, "failure_error", stringField(listenerReport, "failure_reason", "нет"))} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Desired listener</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Состояние" value={desiredListenerConfig?.desired_state || "н/д"} />
|
||
<StateLine label="Режим порта" value={desiredListenerConfig?.listen_port_mode || "н/д"} />
|
||
<StateLine label="Listen addr" value={desiredListenerConfig?.listen_addr || "н/д"} />
|
||
<StateLine label="Auto range" value={desiredListenerConfig ? `${desiredListenerConfig.auto_port_start || "н/д"}-${desiredListenerConfig.auto_port_end || "н/д"}` : "н/д"} />
|
||
<StateLine label="Advertise endpoint" value={desiredListenerConfig?.advertise_endpoint || "auto-discovery"} />
|
||
<StateLine label="Advertise transport" value={desiredListenerConfig?.advertise_transport || "н/д"} />
|
||
<StateLine label="Connectivity" value={desiredListenerConfig?.connectivity_mode || "н/д"} />
|
||
<StateLine label="NAT" value={desiredListenerConfig?.nat_type || "н/д"} />
|
||
<StateLine label="Region/site" value={desiredListenerConfig?.region || "н/д"} />
|
||
<StateLine label="Version" value={desiredListenerConfig?.config_version || "н/д"} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Что узел сообщает кластеру</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Advertised endpoint" value={advertisedEndpoint || "не прислан"} />
|
||
<StateLine label="Transport" value={reportedTransport} />
|
||
<StateLine label="Connectivity" value={reportedConnectivity} />
|
||
<StateLine label="NAT" value={reportedNat} />
|
||
<StateLine label="Region/site" value={reportedRegion} />
|
||
<StateLine label="Observed" value={reportedObservedAt} />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Исходящий control-channel</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Status" value={stringField(outboundReport, "status", "не прислан")} />
|
||
<StateLine label="Direction" value={stringField(outboundReport, "direction", "н/д")} />
|
||
<StateLine label="Transport" value={stringField(outboundReport, "transport", "н/д")} />
|
||
<StateLine label="Control Plane" value={stringField(outboundReport, "control_plane_url", "н/д")} />
|
||
<StateLine label="Reverse usable" value={stringField(outboundReport, "usable_for_inbound_control", "н/д")} />
|
||
<StateLine label="Inbound required" value={stringField(outboundReport, "inbound_listener_required", "н/д")} />
|
||
<StateLine label="Relay ready" value={stringField(outboundReport, "peer_connection_relay_ready", "0")} />
|
||
<StateLine label="Waiting rendezvous" value={stringField(outboundReport, "peer_connection_waiting", "0")} />
|
||
<StateLine label="Rendezvous leases" value={stringField(outboundReport, "rendezvous_lease_count", "0")} />
|
||
<StateLine label="Listener conflict" value={stringField(outboundReport, "listener_port_conflict", "false")} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Наличие сетевых отчетов</h4>
|
||
<div className="summaryChips">
|
||
{networkDiagnostics.map((item) => (
|
||
<span key={item} className={item.includes("не прислан") || item.includes("не загружен") ? "pill warn" : "pill good"}>
|
||
{item}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{!endpointReport && !listenerReport && (
|
||
<p className="muted">
|
||
У этого узла есть heartbeat/mesh manager данные, но агент не передал адресный отчет. До обновления агента или включения endpoint/listener report панель может показать связи и config peers, но не может достоверно назвать локальный listen address.
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Endpoint candidates узла</h4>
|
||
<DataTable
|
||
columns={["id", "address", "transport", "reachability", "mode", "nat", "priority", "verified"]}
|
||
rows={listenerCandidates.map((candidate) => [
|
||
stringField(candidate, "endpoint_id", "н/д"),
|
||
stringField(candidate, "address", "н/д"),
|
||
stringField(candidate, "transport", "н/д"),
|
||
stringField(candidate, "reachability", "н/д"),
|
||
stringField(candidate, "connectivity_mode", "н/д"),
|
||
stringField(candidate, "nat_type", "н/д"),
|
||
stringField(candidate, "priority", "н/д"),
|
||
stringField(candidate, "last_verified_at", "н/д"),
|
||
])}
|
||
/>
|
||
</section>
|
||
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>Рабочие peer endpoints из config</h4>
|
||
<DataTable
|
||
columns={["peer", "endpoint"]}
|
||
rows={configuredPeerEndpoints.map(([peerID, endpoint]) => [nodeName(allNodes, peerID), endpoint])}
|
||
/>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>Резервные кандидаты peer</h4>
|
||
<DataTable
|
||
columns={["peer", "address", "transport", "reachability", "mode", "priority"]}
|
||
rows={reservePeerCandidates.slice(0, 20).map(({ peerID, candidate }) => [
|
||
nodeName(allNodes, peerID),
|
||
candidate.address,
|
||
candidate.transport,
|
||
candidate.reachability,
|
||
candidate.connectivity_mode,
|
||
String(candidate.priority),
|
||
])}
|
||
/>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Активные связи этого узла</h4>
|
||
<DataTable
|
||
columns={["peer", "направление", "тип", "статус", "latency", "quality", "путь", "наблюдение"]}
|
||
rows={latestLinks.slice(0, 20).map((link) => {
|
||
const peerID = link.source_node_id === node.id ? link.target_node_id : link.source_node_id;
|
||
return [
|
||
nodeName(allNodes, peerID),
|
||
link.source_node_id === node.id ? "out" : "in",
|
||
meshLinkObservationLabel(link),
|
||
link.link_status,
|
||
link.latency_ms == null ? "н/д" : `${link.latency_ms}мс`,
|
||
link.quality_score == null ? "н/д" : String(link.quality_score),
|
||
meshLinkRoutePathSummary(link, allNodes),
|
||
formatDate(link.observed_at),
|
||
];
|
||
})}
|
||
/>
|
||
{failedLinks.length > 0 && <p className="muted">Проблемных связей: {failedLinks.length}. Их статус виден в таблице выше.</p>}
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Проверка адресов peer-to-peer</h4>
|
||
<DataTable
|
||
columns={["peer", "status", "selected endpoint", "candidate", "latency", "attempts", "failure"]}
|
||
rows={managerProbeResults.slice(0, 20).map((result) => [
|
||
nodeName(allNodes, stringField(result, "node_id", "")),
|
||
stringField(result, "link_status", "н/д"),
|
||
stringField(result, "selected_endpoint", stringField(result, "endpoint", "н/д")),
|
||
stringField(result, "selected_candidate_id", "н/д"),
|
||
stringField(result, "latency_ms", "н/д"),
|
||
peerCandidateProbeSummary(result),
|
||
stringField(result, "failure_reason", "нет"),
|
||
])}
|
||
/>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "mesh" && (
|
||
<>
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>Mesh control-plane</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Recovery" value={meshRecoverySummary(latestHeartbeat)} />
|
||
<StateLine label="Intents" value={meshConnectionIntentSummary(latestHeartbeat)} />
|
||
<StateLine label="Manager" value={meshConnectionManagerSummary(latestHeartbeat)} />
|
||
<StateLine label="Rendezvous" value={meshRendezvousLeaseSummary(latestHeartbeat)} />
|
||
<StateLine label="Path decisions" value={meshRoutePathDecisionSummary(latestHeartbeat)} />
|
||
<StateLine label="Route generation" value={meshRouteGenerationSummary(latestHeartbeat)} />
|
||
<StateLine label="Route health" value={meshRouteHealthConfigSummary(latestHeartbeat)} />
|
||
<StateLine
|
||
label="Service-channel feedback"
|
||
value={
|
||
serviceChannelFeedback
|
||
? `${serviceChannelFeedback.healthy_route_count} healthy / ${serviceChannelFeedback.degraded_route_count} degraded / ${serviceChannelFeedback.fenced_route_count} fenced`
|
||
: "н/д"
|
||
}
|
||
/>
|
||
<StateLine
|
||
label="Recovery policy"
|
||
value={
|
||
serviceChannelFeedback?.recovery_policy
|
||
? `${serviceChannelFeedback.recovery_policy.source} p${serviceChannelFeedback.recovery_policy.hysteresis_penalty} promote ${serviceChannelFeedback.recovery_policy.promotion_min_samples}`
|
||
: "н/д"
|
||
}
|
||
/>
|
||
<StateLine
|
||
label="Route policy"
|
||
value={
|
||
syntheticConfig?.route_path_decisions?.recovery_policy
|
||
? `${syntheticConfig.route_path_decisions.recovery_policy.source} fail/drop/slow ${syntheticConfig.route_path_decisions.recovery_policy.demotion_failure_threshold}/${syntheticConfig.route_path_decisions.recovery_policy.demotion_drop_threshold}/${syntheticConfig.route_path_decisions.recovery_policy.demotion_slow_threshold}`
|
||
: "н/д"
|
||
}
|
||
/>
|
||
<StateLine label="Config version" value={syntheticConfig?.config_version || "н/д"} />
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>Scoped config counts</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Peer endpoints" value={String(configuredPeerEndpoints.length)} />
|
||
<StateLine label="Endpoint candidates" value={String(configuredPeerCandidates.length)} />
|
||
<StateLine label="Peer directory" value={String(syntheticConfig?.peer_directory?.length || 0)} />
|
||
<StateLine label="Recovery seeds" value={String(syntheticConfig?.recovery_seeds?.length || 0)} />
|
||
<StateLine label="Rendezvous leases" value={String(syntheticConfig?.rendezvous_leases?.length || 0)} />
|
||
<StateLine label="Routes" value={String(syntheticConfig?.routes?.length || 0)} />
|
||
<StateLine label="Fenced routes" value={String(serviceChannelFeedback?.fenced_route_count || 0)} />
|
||
<StateLine label="Remediation commands" value={String(serviceChannelRemediationCommands.length)} />
|
||
<StateLine
|
||
label="Feedback provenance"
|
||
value={
|
||
serviceChannelFeedback
|
||
? `missing ${serviceChannelFeedback.missing_provenance_count || 0} / stale policy ${serviceChannelFeedback.stale_policy_count || 0} / stale gen ${serviceChannelFeedback.stale_generation_count || 0}`
|
||
: "н/д"
|
||
}
|
||
/>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<section className="nodePanel">
|
||
<h4>Route decisions</h4>
|
||
<DataTable
|
||
columns={["route", "replacement", "source", "destination", "effective hops", "decision", "score", "expires"]}
|
||
rows={(syntheticConfig?.route_path_decisions?.decisions || []).map((decision) => [
|
||
shortId(decision.route_id),
|
||
decision.replacement_route_id ? shortId(decision.replacement_route_id) : "нет",
|
||
nodeName(allNodes, decision.source_node_id),
|
||
nodeName(allNodes, decision.destination_node_id),
|
||
decision.effective_hops.map((id) => trimNodeName(nodeName(allNodes, id))).join(" > "),
|
||
decision.decision_source || (decision.selected_relay_id ? nodeName(allNodes, decision.selected_relay_id) : "direct"),
|
||
decision.path_score == null ? "н/д" : String(decision.path_score),
|
||
formatDate(decision.expires_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
{serviceChannelRemediationCommands.length > 0 && (
|
||
<section className="nodePanel">
|
||
<h4>Service-channel remediation commands</h4>
|
||
<DataTable
|
||
columns={["channel", "action", "primary", "replacement", "guard", "execution", "reason", "expires"]}
|
||
rows={serviceChannelRemediationCommands.slice(0, 20).map((command) => [
|
||
shortId(command?.channel_id || ""),
|
||
<span className="pill warn">{statusLabel(command?.action || "")}</span>,
|
||
command?.primary_route_id ? shortId(command.primary_route_id) : "н/д",
|
||
command?.replacement_route_id ? shortId(command.replacement_route_id) : "н/д",
|
||
<span className={`pill ${command?.guard_status === "rejected" ? "bad" : command?.guard_status === "allowed" ? "good" : ""}`}>
|
||
{command?.guard_status ? statusLabel(command.guard_status) : "н/д"}
|
||
</span>,
|
||
<span className={`pill ${remediationExecutionTone(command?.execution_status)}`}>
|
||
{command?.execution_status ? statusLabel(command.execution_status) : "н/д"}
|
||
{command?.execution_reason ? ` / ${statusLabel(command.execution_reason)}` : ""}
|
||
</span>,
|
||
command?.reason || "н/д",
|
||
command?.expires_at ? formatDate(command.expires_at) : "н/д",
|
||
])}
|
||
/>
|
||
</section>
|
||
)}
|
||
<section className="nodePanel">
|
||
<h4>Service-channel route feedback</h4>
|
||
<DataTable
|
||
columns={["route", "service", "status", "recovery", "score", "reasons", "failures", "duration", "expires"]}
|
||
rows={serviceChannelFeedbackItems.slice(0, 40).map((item) => [
|
||
shortId(item.route_id),
|
||
item.service_class,
|
||
<span className={`pill ${serviceChannelFeedbackTone(item.feedback_status)}`}>{statusLabel(item.feedback_status)}</span>,
|
||
item.recovery_state ? (
|
||
<span className={`pill ${serviceChannelRecoveryTone(item.recovery_state)}`}>
|
||
{item.recovery_demoted ? `demoted ${item.recovery_reason ? statusLabel(item.recovery_reason) : ""}` : item.recovery_promoted ? "promoted" : statusLabel(item.recovery_state)}
|
||
{item.recovery_hysteresis_penalty ? ` -${item.recovery_hysteresis_penalty}` : ""}
|
||
</span>
|
||
) : item.stale_policy || item.stale_generation ? (
|
||
<span className="pill warn">{statusLabel(item.stale_reason || "stale")}</span>
|
||
) : item.provenance_missing ? (
|
||
<span className="pill warn">provenance missing</span>
|
||
) : (
|
||
"нет"
|
||
),
|
||
String(item.score_adjustment),
|
||
(item.reasons || []).join(", ") || "нет",
|
||
String(item.consecutive_failures || 0),
|
||
item.last_send_duration_ms == null ? "н/д" : `${item.last_send_duration_ms}мс`,
|
||
formatDate(item.expires_at),
|
||
])}
|
||
/>
|
||
{serviceChannelFeedbackItems.length === 0 && (
|
||
<p className="muted">Пока нет свежих наблюдений. Узел будет присылать их после реального traffic через service-channel runtime.</p>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "services" && (
|
||
<>
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>{labels.nodeRoles}</h4>
|
||
<div className="serviceTags">
|
||
{activeRoles.length === 0 && <p className="muted">{labels.noRoles}</p>}
|
||
{activeRoles.map((role) => (
|
||
<div className="serviceTag" key={role.id}>
|
||
<strong>{roleDisplayLabel(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, labels)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Capabilities</h4>
|
||
<div className="summaryChips">
|
||
{capabilityEntries.length === 0 && <span className="muted">Нет capability heartbeat.</span>}
|
||
{capabilityEntries.slice(0, 40).map(([key, value]) => (
|
||
<span className={value === true ? "pill good" : "pill"} key={key}>
|
||
{key}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>{labels.desiredServices}</h4>
|
||
<DataTable
|
||
columns={["service", "desired", "runtime", "version", "updated"]}
|
||
rows={desiredWorkloads.map((workload) => [
|
||
workload.service_type,
|
||
statusLabel(workload.desired_state),
|
||
workload.runtime_mode,
|
||
workload.version || "не закреплена",
|
||
formatDate(workload.updated_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>{labels.observedServices}</h4>
|
||
<DataTable
|
||
columns={["service", "reported", "runtime", "version", "observed"]}
|
||
rows={observedWorkloads.map((workload) => [
|
||
workload.service_type,
|
||
statusLabel(workload.reported_state),
|
||
workload.runtime_mode,
|
||
workload.version || "н/д",
|
||
formatDate(workload.observed_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "telemetry" && (
|
||
<>
|
||
<section className="nodePanel">
|
||
<h4>{labels.nodeTelemetry}</h4>
|
||
<TelemetryStrip items={telemetry} emptyText={labels.noTelemetry} />
|
||
<div className="stateList">
|
||
<StateLine label="Disk" value={`${formatBytes(latestTelemetry?.disk_used_bytes)} / ${formatBytes(latestTelemetry?.disk_total_bytes)}`} />
|
||
<StateLine label="Network RX/TX" value={`${formatBytes(latestTelemetry?.network_rx_bytes)} / ${formatBytes(latestTelemetry?.network_tx_bytes)}`} />
|
||
<StateLine label="Payload" value={latestTelemetry?.payload ? compactJSON(latestTelemetry.payload) : "н/д"} />
|
||
</div>
|
||
</section>
|
||
<section className="nodePanel">
|
||
<h4>{labels.recentHeartbeats}</h4>
|
||
<DataTable
|
||
columns={["состояние", "версия", "listener", "mesh recovery", "mesh intents", "rv leases", "path decisions", "route gen", "route health", "наблюдение"]}
|
||
rows={heartbeats.slice(0, 10).map((heartbeat) => [
|
||
heartbeat.health_status,
|
||
heartbeat.reported_version || "неизвестно",
|
||
meshListenerSummary(heartbeat),
|
||
meshRecoverySummary(heartbeat),
|
||
meshConnectionIntentSummary(heartbeat),
|
||
meshRendezvousLeaseSummary(heartbeat),
|
||
meshRoutePathDecisionSummary(heartbeat),
|
||
meshRouteGenerationSummary(heartbeat),
|
||
meshRouteHealthConfigSummary(heartbeat),
|
||
formatDate(heartbeat.observed_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "updates" && (
|
||
<>
|
||
<div className="nodeDetailGrid">
|
||
<section className="nodePanel">
|
||
<h4>Текущая сборка</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Node-agent version" value={node.reported_version || latestHeartbeat?.reported_version || "неизвестно"} />
|
||
<StateLine label="План" value={updatePlan ? `${updatePlan.action}: ${updatePlan.reason}` : "не загружен"} />
|
||
<StateLine label="Product" value={updatePlan?.product || "rap-node-agent"} />
|
||
<StateLine label="Target" value={updatePlan?.target_version || "н/д"} />
|
||
<StateLine label="Strategy" value={updatePlan?.strategy || "н/д"} />
|
||
<StateLine label="Rollback" value={updatePlan?.rollback_allowed ? "разрешен" : "нет"} />
|
||
<StateLine label="Artifact" value={updatePlan?.artifact ? `${updatePlan.artifact.kind} ${updatePlan.artifact.os}/${updatePlan.artifact.arch}` : "н/д"} />
|
||
</div>
|
||
<div className="actions">
|
||
<button className="primary" disabled={!onSetUpdatePolicy} onClick={() => onSetUpdatePolicy?.(node, "rap-node-agent", null)}>
|
||
Node-agent latest
|
||
</button>
|
||
<button className="ghost" disabled={!onSetUpdatePolicy || !updatePlan?.target_version} onClick={() => onSetUpdatePolicy?.(node, "rap-node-agent", updatePlan?.target_version || null)}>
|
||
Повторить target
|
||
</button>
|
||
<button className="ghost" disabled={!onSetUpdatePolicy} onClick={() => onSetUpdatePolicy?.(node, "rap-host-agent", null)}>
|
||
Host-agent latest
|
||
</button>
|
||
</div>
|
||
<p className="muted">
|
||
Latest означает policy без закрепленной версии: updater будет брать свежий active release своего канала при следующем цикле или heartbeat hint.
|
||
</p>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Последние отчеты updater</h4>
|
||
<div className="stateList">
|
||
<StateLine label="Updater health" value={`${updaterHealth.label}: ${updaterHealth.detail}`} />
|
||
<StateLine label="rap-node-agent" value={updateStatusLine(latestNodeAgentUpdate)} />
|
||
<StateLine label="rap-host-agent" value={updateStatusLine(latestHostAgentUpdate)} />
|
||
<StateLine label="Всего отчетов" value={String(updateStatuses.length)} />
|
||
<StateLine label="Последний отчет" value={formatDate(latestAnyUpdate?.observed_at)} />
|
||
</div>
|
||
<div className="summaryChips">
|
||
<span className={`pill ${updaterHealth.tone}`}>{updaterHealth.label}</span>
|
||
{latestNodeAgentUpdate && <span className={`pill ${updateStatusTone(latestNodeAgentUpdate)}`}>node-agent: {latestNodeAgentUpdate.status}</span>}
|
||
{latestHostAgentUpdate && <span className={`pill ${updateStatusTone(latestHostAgentUpdate)}`}>host-agent: {latestHostAgentUpdate.status}</span>}
|
||
{!latestNodeAgentUpdate && !latestHostAgentUpdate && <span className="pill warn">updater пока не отчитался</span>}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="nodePanel">
|
||
<h4>История обновлений</h4>
|
||
<DataTable
|
||
columns={["product", "current", "target", "phase", "status", "attempt", "error", "observed"]}
|
||
rows={updateStatuses.slice(0, 40).map((status) => [
|
||
status.product,
|
||
status.current_version || "н/д",
|
||
status.target_version || "н/д",
|
||
status.phase,
|
||
<span className={`pill ${updateStatusTone(status)}`}>{status.status}</span>,
|
||
status.attempt_id ? shortId(status.attempt_id) : "н/д",
|
||
status.error_message || "нет",
|
||
formatDate(status.observed_at),
|
||
])}
|
||
/>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Windows repair/update command</h4>
|
||
<p className="muted">
|
||
Для существующего Windows-узла эта команда переустанавливает wrapper updater без нового join-token, сохраняет local state и запускает обновление до актуальной сборки.
|
||
</p>
|
||
<div className="stateList compact">
|
||
<StateLine label="Когда выполнять" value="если updater stale, host-agent не отчитался или Windows-узел не доходит до target version" />
|
||
<StateLine label="Control Plane" value={externalPreferredControlPlaneEndpoint()} />
|
||
<StateLine label="Join-token" value="не нужен для repair существующего узла" />
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
onClick={() => downloadTextFile(windowsRepairUpdaterCmdFileName(node), windowsRepairUpdaterCmdCommand(node, primaryClusterID))}
|
||
>
|
||
Скачать repair .cmd
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void copyTextToClipboard(windowsRepairUpdaterCmdCommand(node, primaryClusterID))}
|
||
>
|
||
Скопировать команду
|
||
</button>
|
||
</div>
|
||
<pre className="codePreview">{windowsRepairUpdaterCmdCommand(node, primaryClusterID)}</pre>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Linux repair/update command</h4>
|
||
<p className="muted">
|
||
Для существующего Ubuntu/Linux-узла эта команда восстанавливает systemd updater без нового join-token, сохраняет local state и делает одноразовую проверку обновления.
|
||
</p>
|
||
<div className="stateList compact">
|
||
<StateLine label="Когда выполнять" value="если host-agent не отчитался, updater stale или Linux-узел не доходит до target version" />
|
||
<StateLine label="Control Plane" value={externalPreferredControlPlaneEndpoint()} />
|
||
<StateLine label="Join-token" value="не нужен для repair существующего узла" />
|
||
</div>
|
||
<div className="actions">
|
||
<button
|
||
className="primary"
|
||
onClick={() => downloadTextFile(linuxRepairUpdaterShFileName(node), linuxRepairUpdaterShCommand(node, primaryClusterID))}
|
||
>
|
||
Скачать repair .sh
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void copyTextToClipboard(linuxRepairUpdaterShCommand(node, primaryClusterID))}
|
||
>
|
||
Скопировать команду
|
||
</button>
|
||
</div>
|
||
<pre className="codePreview">{linuxRepairUpdaterShCommand(node, primaryClusterID)}</pre>
|
||
</section>
|
||
|
||
<section className="nodePanel">
|
||
<h4>Payload последнего отчета</h4>
|
||
<div className="rawDetailsGrid">
|
||
<RawJSON title="rap-node-agent update status" value={latestNodeAgentUpdate} />
|
||
<RawJSON title="rap-host-agent update status" value={latestHostAgentUpdate} />
|
||
<RawJSON title="Update plan" value={updatePlan} />
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "raw" && (
|
||
<section className="nodePanel">
|
||
<h4>Raw данные узла</h4>
|
||
<div className="rawDetailsGrid">
|
||
<RawJSON title="Последний heartbeat metadata" value={latestHeartbeat?.metadata} />
|
||
<RawJSON title="Heartbeat capabilities" value={latestHeartbeat?.capabilities} />
|
||
<RawJSON title="Heartbeat service states" value={latestHeartbeat?.service_states} />
|
||
<RawJSON title="Synthetic mesh config" value={syntheticConfig} />
|
||
<RawJSON title="Listener report" value={listenerReport} />
|
||
<RawJSON title="Endpoint report" value={endpointReport} />
|
||
<RawJSON title="Peer recovery report" value={recoveryReport} />
|
||
<RawJSON title="Connection intent report" value={intentReport} />
|
||
<RawJSON title="Connection manager report" value={managerReport} />
|
||
<RawJSON title="Rendezvous lease report" value={rendezvousReport} />
|
||
<RawJSON title="Route decision report" value={routeDecisionReport} />
|
||
<RawJSON title="Route generation report" value={routeGenerationReport} />
|
||
<RawJSON title="Route health report" value={routeHealthReport} />
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RawJSON({ title, value }: { title: string; value: unknown }) {
|
||
return (
|
||
<details className="rawBlock">
|
||
<summary>{title}</summary>
|
||
<pre>{value == null ? "н/д" : JSON.stringify(value, null, 2)}</pre>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
type NodeRuntimeConnectivity = {
|
||
agentLabel: string;
|
||
agentTone: string;
|
||
clientLabel: string;
|
||
clientTone: string;
|
||
outboundLabel: string;
|
||
outboundTone: string;
|
||
inboundLabel: string;
|
||
inboundTone: string;
|
||
address: string;
|
||
detail: string;
|
||
};
|
||
|
||
function RuntimeBadges({ runtime }: { runtime: NodeRuntimeConnectivity }) {
|
||
return (
|
||
<div className="runtimeBadges">
|
||
<span className={`pill ${runtime.agentTone}`}>{runtime.agentLabel}</span>
|
||
<span className={`pill ${runtime.clientTone}`}>{runtime.clientLabel}</span>
|
||
<span className={`pill ${runtime.outboundTone}`}>{runtime.outboundLabel}</span>
|
||
<span className={`pill ${runtime.inboundTone}`}>{runtime.inboundLabel}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeLinkEndpoint({
|
||
node,
|
||
fallback,
|
||
heartbeatsByNode,
|
||
meshLinks,
|
||
}: {
|
||
node?: ClusterNode;
|
||
fallback: string;
|
||
heartbeatsByNode: Record<string, NodeHeartbeat[]>;
|
||
meshLinks: MeshLink[];
|
||
}) {
|
||
if (!node) {
|
||
return fallback;
|
||
}
|
||
const runtime = nodeRuntimeConnectivity(node, heartbeatsByNode[node.id] || [], meshLinks);
|
||
return (
|
||
<div className="nodeEndpointCell">
|
||
<strong>{node.name}</strong>
|
||
<RuntimeBadges runtime={runtime} />
|
||
<small>{runtime.address}</small>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FabricTopology({
|
||
nodes,
|
||
links,
|
||
syntheticMeshConfigsByNode,
|
||
entryPoints,
|
||
entryPointNodesById,
|
||
egressPools,
|
||
egressPoolNodesById,
|
||
rolesByNode,
|
||
workloadsByNode,
|
||
telemetryByNode,
|
||
labels,
|
||
emptyText,
|
||
}: {
|
||
nodes: ClusterNode[];
|
||
links: MeshLink[];
|
||
syntheticMeshConfigsByNode: Record<string, NodeSyntheticMeshConfig>;
|
||
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).filter((link) => link.source_node_id !== link.target_node_id);
|
||
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
|
||
const topologyLinks = latestLinks.map((link) => ({ link, status: topologyLinkRuntimeStatus(link, latestLinks, nodesById) }));
|
||
const realLinks = topologyLinks.filter((item) => item.status === "reachable");
|
||
const oneWayLinks = topologyLinks.filter((item) => item.status === "one_way");
|
||
const staleLinks = topologyLinks.filter((item) => item.status === "stale");
|
||
const problemLinks = topologyLinks.filter((item) => item.status !== "reachable" && item.status !== "one_way" && item.status !== "stale");
|
||
const configuredLinks = configuredTopologyLinks(nodes, syntheticMeshConfigsByNode);
|
||
const viewBox = topologyViewBox(nodes.length, entryPoints.length, egressPools.length);
|
||
const nodeRadius = topologyNodeRadius(nodes.length);
|
||
const nodePositions = topologyNodePositions(nodes, viewBox.height, nodeRadius);
|
||
const entryPositions = new Map(entryPoints.map((entryPoint, index) => [entryPoint.id, topologyPoint(170, index, entryPoints.length, 150, viewBox.height - 100)] as const));
|
||
const egressPositions = new Map(egressPools.map((pool, index) => [pool.id, topologyPoint(950, index, egressPools.length, 150, viewBox.height - 100)] as const));
|
||
const observedPairs = new Set(topologyLinks.filter((item) => item.status !== "stale").map((item) => `${item.link.source_node_id}->${item.link.target_node_id}`));
|
||
const meshStatusByNode = new Map(nodes.map((node) => [node.id, topologyNodeMeshStatus(node.id, topologyLinks)] as const));
|
||
const visibleConfiguredLinks = configuredLinks.filter((link) => !observedPairs.has(`${link.source_node_id}->${link.target_node_id}`));
|
||
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 ${viewBox.width} ${viewBox.height}`} 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="268" height={viewBox.height - 100} rx="24" className="topologyZone ingress" />
|
||
<rect x="330" y="58" width="460" height={viewBox.height - 100} rx="24" className="topologyZone core" />
|
||
<rect x="816" y="58" width="268" height={viewBox.height - 100} rx="24" className="topologyZone egress" />
|
||
<text x="170" y="98" className="topologyLayerLabel">
|
||
{labels.fabricIngressLayer}
|
||
</text>
|
||
<text x="560" y="98" className="topologyLayerLabel">
|
||
{labels.fabricNodeLayer}
|
||
</text>
|
||
<text x="950" 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 - nodeRadius - 8}
|
||
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 + nodeRadius + 8}
|
||
y1={source.y}
|
||
x2={target.x - 78}
|
||
y2={target.y}
|
||
className={`topologyPlacementLink ${assignment.status === "active" ? "good" : "weak"}`}
|
||
markerEnd="url(#arrow)"
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{visibleConfiguredLinks.map((link) => {
|
||
const source = nodePositions.get(link.source_node_id);
|
||
const target = nodePositions.get(link.target_node_id);
|
||
if (!source || !target) {
|
||
return null;
|
||
}
|
||
const edge = topologyEdgePoints(source, target, nodeRadius + 8);
|
||
return (
|
||
<line
|
||
key={link.id}
|
||
x1={edge.x1}
|
||
y1={edge.y1}
|
||
x2={edge.x2}
|
||
y2={edge.y2}
|
||
className="topologyConfiguredLink"
|
||
markerEnd="url(#arrow)"
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{topologyLinks.map(({ link, status }) => {
|
||
const source = nodePositions.get(link.source_node_id);
|
||
const target = nodePositions.get(link.target_node_id);
|
||
if (!source || !target) {
|
||
return null;
|
||
}
|
||
const edge = topologyEdgePoints(source, target, nodeRadius + 8);
|
||
const midX = (edge.x1 + edge.x2) / 2;
|
||
const midY = (edge.y1 + edge.y2) / 2;
|
||
return (
|
||
<g key={link.id || `${link.source_node_id}-${link.target_node_id}`}>
|
||
<line
|
||
x1={edge.x1}
|
||
y1={edge.y1}
|
||
x2={edge.x2}
|
||
y2={edge.y2}
|
||
className={`topologyLink ${linkTone(link, status)}`}
|
||
markerEnd="url(#arrow)"
|
||
/>
|
||
<text x={midX} y={midY - 8} className="topologyLinkLabel">
|
||
{meshLinkTopologyLabel(link, status)}
|
||
</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 labelSize = topologyNodeLabelSize(nodes.length);
|
||
const activeRoles = activeRoleAssignments(rolesByNode[node.id] || []);
|
||
const meshStatus = meshStatusByNode.get(node.id) || "isolated";
|
||
return (
|
||
<g key={node.id} className="topologyNode">
|
||
<circle cx={point.x} cy={point.y} r={nodeRadius} className={`topologyNodeCircle ${node.health_status}`} />
|
||
<text x={point.x} y={point.y - labelSize.nameOffset} className="topologyNodeName" style={{ fontSize: labelSize.name }}>
|
||
{trimNodeName(node.name, labelSize.maxChars)}
|
||
</text>
|
||
<text x={point.x} y={point.y + labelSize.metaOffset} className="topologyNodeMeta" style={{ fontSize: labelSize.meta }}>
|
||
{activeRoles.length} активн. ролей / {(workloadsByNode[node.id] || []).length} серв.
|
||
</text>
|
||
<text x={point.x} y={point.y + labelSize.memoryOffset} className="topologyNodeMeta" style={{ fontSize: labelSize.meta }}>
|
||
mesh: {statusLabel(meshStatus)}
|
||
</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>
|
||
);
|
||
})}
|
||
{topologyLinks.length === 0 && visibleConfiguredLinks.length === 0 && placementCount === 0 && (
|
||
<text x={viewBox.width / 2} y={viewBox.height - 34} className="topologyEmpty">
|
||
{emptyText}
|
||
</text>
|
||
)}
|
||
</svg>
|
||
<div className="topologyLegend">
|
||
<span>
|
||
<i className="legendLine placement" /> {labels.placementIntent}: {placementCount}
|
||
</span>
|
||
<span>
|
||
<i className="legendLine observed" /> реальные: {realLinks.length}
|
||
</span>
|
||
<span>
|
||
<i className="legendLine oneWay" /> one-way: {oneWayLinks.length}
|
||
</span>
|
||
<span>
|
||
<i className="legendLine stale" /> stale: {staleLinks.length}
|
||
</span>
|
||
<span>
|
||
<i className="legendLine problem" /> проблемы: {problemLinks.length}
|
||
</span>
|
||
<span>
|
||
<i className="legendLine configured" /> configured: {visibleConfiguredLinks.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>{statusLabel(node.health_status)} / mesh {statusLabel(meshStatusByNode.get(node.id) || "isolated")}</span>
|
||
<small>{summarizeRoles(rolesByNode[node.id] || [])}</small>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
type TopologyLinkStatus = "reachable" | "one_way" | "stale" | "degraded" | "unreachable" | "unknown";
|
||
|
||
function topologyNodeMeshStatus(nodeId: string, links: { link: MeshLink; status: TopologyLinkStatus }[]) {
|
||
const peerLinks = links.filter((item) => item.link.source_node_id !== item.link.target_node_id && (item.link.source_node_id === nodeId || item.link.target_node_id === nodeId));
|
||
if (peerLinks.some((item) => item.status === "reachable" || item.status === "one_way")) {
|
||
return "connected";
|
||
}
|
||
if (peerLinks.some((item) => item.status !== "stale")) {
|
||
return "degraded";
|
||
}
|
||
return "isolated";
|
||
}
|
||
|
||
function topologyLinkRuntimeStatus(link: MeshLink, links: MeshLink[], nodesById: Map<string, ClusterNode>): TopologyLinkStatus {
|
||
if (isMeshLinkStale(link, nodesById)) {
|
||
return "stale";
|
||
}
|
||
if (link.link_status !== "reachable") {
|
||
return link.link_status === "degraded" || link.link_status === "unreachable" ? link.link_status : "unknown";
|
||
}
|
||
const reverse = links.find((candidate) => candidate.source_node_id === link.target_node_id && candidate.target_node_id === link.source_node_id && !isMeshLinkStale(candidate, nodesById));
|
||
if (!reverse || reverse.link_status !== "reachable") {
|
||
return "one_way";
|
||
}
|
||
return "reachable";
|
||
}
|
||
|
||
function isMeshLinkStale(link: MeshLink, nodesById?: Map<string, ClusterNode>) {
|
||
if (link.link_status === "stale" || link.metadata?.derived_link_stale === true) {
|
||
return true;
|
||
}
|
||
const observedAt = new Date(link.observed_at).getTime();
|
||
if (!Number.isFinite(observedAt) || Date.now() - observedAt > 2 * 60 * 1000) {
|
||
return true;
|
||
}
|
||
if (!nodesById) {
|
||
return false;
|
||
}
|
||
const source = nodesById.get(link.source_node_id);
|
||
const target = nodesById.get(link.target_node_id);
|
||
return source?.health_status !== "healthy" || target?.health_status !== "healthy";
|
||
}
|
||
|
||
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 topologyViewBox(nodeCount: number, entryCount: number, egressCount: number) {
|
||
const maxColumnCount = Math.max(nodeCount, entryCount, egressCount, 1);
|
||
const rows = Math.max(Math.ceil(nodeCount / 3), maxColumnCount);
|
||
return {
|
||
width: 1120,
|
||
height: Math.max(640, 220 + rows * 104),
|
||
};
|
||
}
|
||
|
||
function topologyNodeRadius(nodeCount: number) {
|
||
if (nodeCount > 48) {
|
||
return 26;
|
||
}
|
||
if (nodeCount > 24) {
|
||
return 32;
|
||
}
|
||
if (nodeCount > 12) {
|
||
return 40;
|
||
}
|
||
return 52;
|
||
}
|
||
|
||
function topologyNodeLabelSize(nodeCount: number) {
|
||
if (nodeCount > 48) {
|
||
return { name: 12, meta: 9, nameOffset: 7, metaOffset: 7, memoryOffset: 20, maxChars: 10 };
|
||
}
|
||
if (nodeCount > 24) {
|
||
return { name: 14, meta: 10, nameOffset: 8, metaOffset: 9, memoryOffset: 24, maxChars: 12 };
|
||
}
|
||
if (nodeCount > 12) {
|
||
return { name: 16, meta: 12, nameOffset: 10, metaOffset: 11, memoryOffset: 28, maxChars: 14 };
|
||
}
|
||
return { name: 21, meta: 15, nameOffset: 12, metaOffset: 10, memoryOffset: 31, maxChars: 18 };
|
||
}
|
||
|
||
function topologyNodePositions(nodes: ClusterNode[], height: number, radius: number) {
|
||
const columns = nodes.length > 24 ? 4 : nodes.length > 8 ? 3 : nodes.length > 3 ? 2 : 1;
|
||
const rows = Math.max(1, Math.ceil(nodes.length / columns));
|
||
const left = 410;
|
||
const right = 710;
|
||
const xStep = columns === 1 ? 0 : (right - left) / (columns - 1);
|
||
const top = 142;
|
||
const bottom = height - 96;
|
||
const yStep = rows === 1 ? 0 : (bottom - top) / (rows - 1);
|
||
return new Map(
|
||
nodes.map((node, index) => {
|
||
const column = index % columns;
|
||
const row = Math.floor(index / columns);
|
||
return [
|
||
node.id,
|
||
{
|
||
x: Math.round(columns === 1 ? 560 : left + xStep * column),
|
||
y: Math.round(rows === 1 ? (top + bottom) / 2 : top + yStep * row),
|
||
},
|
||
] as const;
|
||
}),
|
||
);
|
||
}
|
||
|
||
function topologyEdgePoints(source: { x: number; y: number }, target: { x: number; y: number }, offset: number) {
|
||
const dx = target.x - source.x;
|
||
const dy = target.y - source.y;
|
||
const distance = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||
const ox = (dx / distance) * offset;
|
||
const oy = (dy / distance) * offset;
|
||
return {
|
||
x1: source.x + ox,
|
||
y1: source.y + oy,
|
||
x2: target.x - ox,
|
||
y2: target.y - oy,
|
||
};
|
||
}
|
||
|
||
function configuredTopologyLinks(nodes: ClusterNode[], configsByNode: Record<string, NodeSyntheticMeshConfig>) {
|
||
const knownNodes = new Set(nodes.map((node) => node.id));
|
||
const byPair = new Map<string, { id: string; source_node_id: string; target_node_id: string }>();
|
||
for (const [nodeID, config] of Object.entries(configsByNode)) {
|
||
for (const route of config.routes || []) {
|
||
const hops = route.hops || [];
|
||
for (let index = 0; index < hops.length - 1; index++) {
|
||
const source = hops[index];
|
||
const target = hops[index + 1];
|
||
if (!knownNodes.has(source) || !knownNodes.has(target) || source === target) {
|
||
continue;
|
||
}
|
||
byPair.set(`${source}->${target}`, {
|
||
id: `configured-${nodeID}-${route.route_id}-${index}`,
|
||
source_node_id: source,
|
||
target_node_id: target,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return [...byPair.values()];
|
||
}
|
||
|
||
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[]) {
|
||
const activeRoles = activeRoleAssignments(roles);
|
||
if (activeRoles.length === 0) {
|
||
return "активные роли не назначены";
|
||
}
|
||
return activeRoles.map((role) => `${roleDisplayLabel(role.role)}${role.organization_id ? ` @ ${shortId(role.organization_id)}` : ""}`).join(", ");
|
||
}
|
||
|
||
function activeRoleAssignments(roles: RoleAssignment[]) {
|
||
return roles.filter((role) => role.status === "active");
|
||
}
|
||
|
||
function roleDisplayLabel(role: string) {
|
||
const name = roleDisplayNames[role];
|
||
return name ? `${name} (${role})` : role;
|
||
}
|
||
|
||
function summarizeWorkloads(workloads: WorkloadStatus[]) {
|
||
if (workloads.length === 0) {
|
||
return "нет сервисов";
|
||
}
|
||
return workloads.map((workload) => `${workload.service_type}:${workload.reported_state}`).join(", ");
|
||
}
|
||
|
||
function nodeUpdateSummary(node: ClusterNode, plan: NodeUpdatePlan | undefined, releases: ReleaseVersion[]) {
|
||
const latest =
|
||
releases.find((release) => release.product === "rap-node-agent" && release.channel === "stable" && release.status === "active") ||
|
||
releases.find((release) => release.product === "rap-node-agent" && release.status === "active");
|
||
const current = node.reported_version || "";
|
||
const target = plan?.target_version || latest?.version || "";
|
||
if (node.version_state && node.version_state !== "unknown") {
|
||
return { status: node.version_state, targetLabel: target ? `target ${target}` : "policy target unknown" };
|
||
}
|
||
if (!current) {
|
||
return { status: "unknown", targetLabel: target ? `target ${target}` : "target unknown" };
|
||
}
|
||
if (plan?.action === "update") {
|
||
return { status: "outdated", targetLabel: `target ${plan.target_version || target}` };
|
||
}
|
||
if (target && current !== target) {
|
||
return { status: "outdated", targetLabel: `latest ${target}` };
|
||
}
|
||
if (target && current === target) {
|
||
return { status: "current", targetLabel: `latest ${target}` };
|
||
}
|
||
return { status: plan?.reason === "no_update_policy" ? "no_policy" : "unknown", targetLabel: plan?.reason || "release policy unknown" };
|
||
}
|
||
|
||
function latestUpdateStatus(statuses: NodeUpdateStatus[], product: string) {
|
||
return statuses.find((status) => status.product === product);
|
||
}
|
||
|
||
function updateStatusSummary(status: NodeUpdateStatus | undefined, plan: NodeUpdatePlan | undefined) {
|
||
if (status) {
|
||
return `${status.product}: ${status.phase}/${status.status}`;
|
||
}
|
||
if (plan) {
|
||
return `${plan.action}: ${plan.reason}`;
|
||
}
|
||
return "нет отчета";
|
||
}
|
||
|
||
function updateStatusLine(status: NodeUpdateStatus | undefined) {
|
||
if (!status) {
|
||
return "нет отчета";
|
||
}
|
||
const target = status.target_version ? ` -> ${status.target_version}` : "";
|
||
const error = status.error_message ? `, ошибка: ${status.error_message}` : "";
|
||
return `${status.current_version || "н/д"}${target}, ${status.phase}/${status.status}, ${formatDate(status.observed_at)}${error}`;
|
||
}
|
||
|
||
function vpnPacketStatLine(stats: VPNPacketStats[keyof VPNPacketStats] | undefined) {
|
||
if (!stats) {
|
||
return "нет данных";
|
||
}
|
||
return `push ${stats.pushed || 0} / pop ${stats.popped || 0} / q ${stats.queue_depth || 0} / drop ${stats.dropped || 0}`;
|
||
}
|
||
|
||
function updateStatusTone(status: NodeUpdateStatus | undefined) {
|
||
if (!status) {
|
||
return "warn";
|
||
}
|
||
const value = `${status.phase}:${status.status}`.toLowerCase();
|
||
if (value.includes("error") || value.includes("failed") || value.includes("rollback")) {
|
||
return "bad";
|
||
}
|
||
if (value.includes("success") || value.includes("updated") || value.includes("noop") || value.includes("already_current")) {
|
||
return "good";
|
||
}
|
||
if (value.includes("download") || value.includes("replace") || value.includes("plan") || value.includes("apply")) {
|
||
return "warn";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function serviceChannelFeedbackTone(status: string) {
|
||
const value = status.toLowerCase();
|
||
if (value === "healthy") {
|
||
return "good";
|
||
}
|
||
if (value === "fenced") {
|
||
return "bad";
|
||
}
|
||
if (value === "degraded") {
|
||
return "warn";
|
||
}
|
||
if (value === "operator_retry_cooldown") {
|
||
return "warn";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function serviceChannelRecoveryTone(status: string) {
|
||
const value = status.toLowerCase();
|
||
if (value === "healthy") {
|
||
return "good";
|
||
}
|
||
if (value === "recovered" || value === "cooldown" || value === "degraded") {
|
||
return "warn";
|
||
}
|
||
if (value === "fenced" || value === "demoted") {
|
||
return "bad";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function routeIntentLifecycle(item: MeshRouteIntent) {
|
||
if (item.status === "disabled" || item.lifecycle_status === "disabled") {
|
||
return "disabled";
|
||
}
|
||
if (item.is_expired || item.lifecycle_status === "expired") {
|
||
return "expired";
|
||
}
|
||
const expiresAt = Date.parse(item.policy_expires_at || "");
|
||
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
||
return "expired";
|
||
}
|
||
return item.lifecycle_status || item.status || "active";
|
||
}
|
||
|
||
function routeIntentTone(item: MeshRouteIntent) {
|
||
const lifecycle = routeIntentLifecycle(item);
|
||
if (lifecycle === "active") {
|
||
return "good";
|
||
}
|
||
if (lifecycle === "expired") {
|
||
return "warn";
|
||
}
|
||
if (lifecycle === "disabled") {
|
||
return "";
|
||
}
|
||
return "warn";
|
||
}
|
||
|
||
function routeIntentNodeSelector(selector: Record<string, unknown>) {
|
||
const nodeID = typeof selector.node_id === "string" ? selector.node_id : "";
|
||
if (nodeID) {
|
||
return shortId(nodeID);
|
||
}
|
||
const nodeIDs = Array.isArray(selector.node_ids) ? selector.node_ids.filter((item): item is string => typeof item === "string") : [];
|
||
if (nodeIDs.length > 0) {
|
||
return nodeIDs.map(shortId).join(", ");
|
||
}
|
||
return "selector";
|
||
}
|
||
|
||
function nodeUpdaterSummary(statuses: NodeUpdateStatus[]) {
|
||
const nodeAgent = latestUpdateStatus(statuses, "rap-node-agent");
|
||
const hostAgent = latestUpdateStatus(statuses, "rap-host-agent");
|
||
if (!nodeAgent && !hostAgent) {
|
||
return { label: "updater: нет отчета", detail: "repair/update task не отчитался", tone: "bad" };
|
||
}
|
||
const stale = [nodeAgent, hostAgent].some((status) => status && isUpdateStatusStale(status));
|
||
const missingHost = !hostAgent;
|
||
const stagedHost = hostAgent?.phase === "apply" && hostAgent?.status === "staged";
|
||
const bad = [nodeAgent, hostAgent].some((status) => status && updateStatusTone(status) === "bad");
|
||
const nodeLabel = nodeAgent ? `${nodeAgent.current_version || "?"}->${nodeAgent.target_version || "?"}` : "node ?";
|
||
const hostLabel = hostAgent ? `${hostAgent.current_version || "?"}->${hostAgent.target_version || "?"}` : "host ?";
|
||
const observed = formatDate((hostAgent || nodeAgent)?.observed_at);
|
||
if (bad) {
|
||
return { label: "updater: ошибка", detail: `${nodeLabel}; ${hostLabel}; ${observed}`, tone: "bad" };
|
||
}
|
||
if (missingHost) {
|
||
return { label: "repair updater", detail: `host-agent не отчитался; ${nodeLabel}; ${observed}`, tone: "warn" };
|
||
}
|
||
if (stagedHost) {
|
||
return { label: "host-agent staged", detail: `${hostLabel}; нужен следующий запуск updater`, tone: "warn" };
|
||
}
|
||
if (stale) {
|
||
return { label: "updater: stale", detail: `${nodeLabel}; ${hostLabel}; ${observed}`, tone: "warn" };
|
||
}
|
||
return { label: "updater: ok", detail: `${nodeLabel}; ${hostLabel}; ${observed}`, tone: "good" };
|
||
}
|
||
|
||
function isUpdateStatusStale(status: NodeUpdateStatus) {
|
||
const observed = new Date(status.observed_at).getTime();
|
||
return !Number.isFinite(observed) || Date.now() - observed > 15 * 60 * 1000;
|
||
}
|
||
|
||
function joinTokenName(token: NodeJoinToken) {
|
||
const nodeName = typeof token.scope?.node_name === "string" ? token.scope.node_name : "";
|
||
const purpose = typeof token.scope?.purpose === "string" ? token.scope.purpose : "";
|
||
return nodeName || purpose || shortId(token.id);
|
||
}
|
||
|
||
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, status: TopologyLinkStatus = link.link_status === "reachable" ? "reachable" : "unknown") {
|
||
if (status === "stale") {
|
||
return "stale";
|
||
}
|
||
if (status === "one_way") {
|
||
return "one-way";
|
||
}
|
||
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 objectField(value: unknown): Record<string, unknown> | undefined {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||
}
|
||
|
||
function arrayObjects(value: unknown): Record<string, unknown>[] {
|
||
return Array.isArray(value) ? value.map((item) => objectField(item)).filter((item): item is Record<string, unknown> => Boolean(item)) : [];
|
||
}
|
||
|
||
function stringField(source: Record<string, unknown> | undefined, key: string, fallback = "") {
|
||
const value = source?.[key];
|
||
if (typeof value === "string") {
|
||
return value;
|
||
}
|
||
if (typeof value === "number" || typeof value === "boolean") {
|
||
return String(value);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function firstStringField(source: Record<string, unknown> | undefined, keys: string[]) {
|
||
for (const key of keys) {
|
||
const value = stringField(source, key, "");
|
||
if (value) {
|
||
return value;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function nodeRuntimeConnectivity(node: ClusterNode, heartbeats: NodeHeartbeat[], meshLinks: MeshLink[]): NodeRuntimeConnectivity {
|
||
const heartbeat = heartbeats[0];
|
||
const metadata = heartbeat?.metadata || {};
|
||
const listenerReport = objectField(metadata.mesh_listener_report);
|
||
const endpointReport = objectField(metadata.mesh_endpoint_report);
|
||
const outboundReport = objectField(metadata.mesh_outbound_session_report);
|
||
const managerReport = objectField(metadata.mesh_peer_connection_manager_report);
|
||
const recoveryReport = objectField(metadata.mesh_peer_recovery_report);
|
||
const reportedCandidates = arrayObjects(endpointReport?.endpoint_candidates);
|
||
const firstCandidate = reportedCandidates[0];
|
||
const address =
|
||
firstStringField(endpointReport, ["peer_endpoint", "advertised_endpoint", "endpoint"]) ||
|
||
stringField(firstCandidate, "address", "") ||
|
||
stringField(listenerReport, "effective_listen_addr", "") ||
|
||
"адрес не прислан";
|
||
if (!heartbeat && !node.last_seen_at) {
|
||
return {
|
||
agentLabel: "agent: no heartbeat",
|
||
agentTone: "bad",
|
||
clientLabel: "client: unknown",
|
||
clientTone: "warn",
|
||
outboundLabel: "outbound: no heartbeat",
|
||
outboundTone: "bad",
|
||
inboundLabel: "inbound: unknown",
|
||
inboundTone: "warn",
|
||
address,
|
||
detail: "Узел создан/одобрен, но node-agent еще ни разу не прислал heartbeat.",
|
||
};
|
||
}
|
||
|
||
const readyPeers =
|
||
numberField(managerReport, "peer_connection_ready") ||
|
||
numberField(recoveryReport, "peer_connection_ready") ||
|
||
latestLinkObservations(meshLinks).filter((link) => (link.source_node_id === node.id || link.target_node_id === node.id) && link.link_status === "reachable").length;
|
||
const totalPeers =
|
||
numberField(managerReport, "peer_connection_total") ||
|
||
numberField(recoveryReport, "peer_connection_total") ||
|
||
latestLinkObservations(meshLinks).filter((link) => link.source_node_id === node.id || link.target_node_id === node.id).length;
|
||
const managerFailed = numberField(managerReport, "failed");
|
||
const listenerStatus = stringField(listenerReport, "status", "");
|
||
const portConflict = listenerReport?.port_conflict === true;
|
||
const oneWay =
|
||
listenerReport?.one_way_connectivity === true ||
|
||
stringField(endpointReport, "connectivity_mode", "") === "outbound_only" ||
|
||
numberField(managerReport, "peer_connection_relay_ready") > 0;
|
||
let inboundLabel = "inbound: no report";
|
||
let inboundTone = "warn";
|
||
if (listenerStatus === "listening" || listenerStatus === "auto_rebound") {
|
||
inboundLabel = listenerStatus === "auto_rebound" ? "inbound: auto port" : "inbound: listening";
|
||
inboundTone = "good";
|
||
} else if (listenerStatus === "listen_failed") {
|
||
inboundLabel = portConflict ? "inbound: port busy" : "inbound: failed";
|
||
inboundTone = "bad";
|
||
} else if (listenerStatus === "disabled") {
|
||
inboundLabel = "inbound: disabled";
|
||
inboundTone = oneWay ? "warn" : "bad";
|
||
} else if (endpointReport) {
|
||
inboundLabel = "inbound: advertised";
|
||
inboundTone = "good";
|
||
}
|
||
|
||
let clientLabel = "client: no peers";
|
||
let clientTone = "warn";
|
||
if (readyPeers > 0) {
|
||
clientLabel = `client: ready ${readyPeers}/${Math.max(totalPeers, readyPeers)}`;
|
||
clientTone = "good";
|
||
} else if (managerFailed > 0 || totalPeers > 0) {
|
||
clientLabel = `client: backoff ${readyPeers}/${Math.max(totalPeers, managerFailed)}`;
|
||
clientTone = "bad";
|
||
}
|
||
|
||
const outboundStatus = stringField(outboundReport, "status", "");
|
||
const usableForInboundControl = outboundReport?.usable_for_inbound_control === true;
|
||
const relayReady = numberField(outboundReport, "peer_connection_relay_ready");
|
||
const rendezvousLeases = numberField(outboundReport, "rendezvous_lease_count");
|
||
let outboundLabel = "outbound: no report";
|
||
let outboundTone = "warn";
|
||
if (outboundStatus === "ready") {
|
||
outboundLabel = usableForInboundControl ? "outbound: ready reverse" : "outbound: ready";
|
||
outboundTone = "good";
|
||
} else if (outboundStatus === "backoff" || outboundStatus === "failed") {
|
||
outboundLabel = `outbound: ${outboundStatus}`;
|
||
outboundTone = "bad";
|
||
} else if (oneWay || relayReady > 0 || rendezvousLeases > 0) {
|
||
outboundLabel = "outbound: inferred";
|
||
outboundTone = "warn";
|
||
}
|
||
|
||
const agentTone = node.health_status === "healthy" ? "good" : node.health_status === "unknown" ? "warn" : "bad";
|
||
return {
|
||
agentLabel: heartbeat ? "agent: heartbeat" : "agent: stale",
|
||
agentTone,
|
||
clientLabel: oneWay && readyPeers > 0 ? `${clientLabel} one-way` : clientLabel,
|
||
clientTone,
|
||
outboundLabel,
|
||
outboundTone,
|
||
inboundLabel,
|
||
inboundTone,
|
||
address,
|
||
detail: stringField(listenerReport, "failure_error", stringField(listenerReport, "failure_reason", "")),
|
||
};
|
||
}
|
||
|
||
function numberField(source: Record<string, unknown> | undefined, key: string, fallback = 0) {
|
||
const value = source?.[key];
|
||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||
}
|
||
|
||
function compactJSON(value: unknown) {
|
||
if (value == null) {
|
||
return "н/д";
|
||
}
|
||
const raw = JSON.stringify(value);
|
||
return raw.length > 140 ? `${raw.slice(0, 137)}...` : raw;
|
||
}
|
||
|
||
function peerCandidateProbeSummary(result: Record<string, unknown>) {
|
||
const candidates = arrayObjects(result.candidate_results);
|
||
if (candidates.length === 0) {
|
||
return "н/д";
|
||
}
|
||
return candidates
|
||
.slice(0, 4)
|
||
.map((candidate) => {
|
||
const id = stringField(candidate, "candidate_id", "candidate");
|
||
const status = stringField(candidate, "link_status", "unknown");
|
||
const latency = stringField(candidate, "latency_ms", "");
|
||
return latency && latency !== "0" ? `${id}:${status}:${latency}мс` : `${id}:${status}`;
|
||
})
|
||
.join(", ");
|
||
}
|
||
|
||
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}`];
|
||
if ((report.degraded_decision_count || 0) > 0) {
|
||
parts.push(`degr${report.degraded_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: JoinTokenFormState): Record<string, unknown> {
|
||
const scope: Record<string, unknown> = {
|
||
roles: input.roles,
|
||
node_name: input.nodeName.trim() || null,
|
||
node_group_id: input.nodeGroupId || null,
|
||
ownership_type: input.ownershipType,
|
||
purpose: input.purpose.trim() || null,
|
||
approval: {
|
||
mode: "manual",
|
||
auto_approve: false,
|
||
role_assignment: "manual_after_approval",
|
||
},
|
||
source: "platform_owner_console",
|
||
};
|
||
if (input.installMode === "docker") {
|
||
const endpoint = (input.controlPlaneEndpoint || defaultControlPlaneEndpoint()).trim().replace(/\/$/, "");
|
||
scope.install_profile = "docker";
|
||
scope.backend_url = endpoint;
|
||
scope.control_plane_endpoints = [endpoint];
|
||
scope.image = input.dockerImage || "rap-node-agent:latest";
|
||
if (input.dockerContainerName.trim()) {
|
||
scope.container_name = input.dockerContainerName.trim();
|
||
}
|
||
scope.artifact_endpoints = csvValues(input.artifactEndpoints || defaultArtifactEndpoint());
|
||
if (input.dockerImageArtifactSHA256.trim()) {
|
||
scope.docker_image_artifact_sha256 = input.dockerImageArtifactSHA256.trim();
|
||
}
|
||
scope.network = input.dockerNetwork || "host";
|
||
scope.restart_policy = "unless-stopped";
|
||
scope.pull_image = Boolean(input.pullImage);
|
||
scope.replace = input.replace !== false;
|
||
scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime !== false;
|
||
scope.mesh_production_forwarding_enabled = false;
|
||
scope.mesh_listen_addr = input.meshListenAddr || ":19131";
|
||
scope.mesh_listen_port_mode = input.meshListenPortMode || "auto";
|
||
scope.mesh_listen_auto_port_start = input.meshListenAutoPortStart || 19131;
|
||
scope.mesh_listen_auto_port_end = input.meshListenAutoPortEnd || 19231;
|
||
if (input.meshAdvertiseEndpoint?.trim()) {
|
||
scope.mesh_advertise_endpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "");
|
||
}
|
||
scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_http";
|
||
scope.mesh_connectivity_mode = input.meshConnectivityMode || "private_lan";
|
||
scope.mesh_nat_type = input.meshNATType || "unknown";
|
||
scope.mesh_region = input.meshRegion || null;
|
||
}
|
||
if (input.installMode === "windows_service") {
|
||
const endpoint = (input.controlPlaneEndpoint || defaultControlPlaneEndpoint()).trim().replace(/\/$/, "");
|
||
scope.install_profile = "windows_service";
|
||
scope.backend_url = endpoint;
|
||
scope.control_plane_endpoints = [endpoint];
|
||
scope.artifact_endpoints = csvValues(input.artifactEndpoints || defaultArtifactEndpoint());
|
||
scope.startup_mode = input.windowsStartupMode || "auto";
|
||
if (input.windowsInstallDir.trim()) {
|
||
scope.install_dir = input.windowsInstallDir.trim();
|
||
}
|
||
if (input.windowsNodeAgentSHA256.trim()) {
|
||
scope.node_agent_artifact_sha256 = input.windowsNodeAgentSHA256.trim();
|
||
}
|
||
scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime !== false;
|
||
scope.mesh_production_forwarding_enabled = false;
|
||
scope.mesh_listen_addr = input.meshListenAddr || ":19131";
|
||
scope.mesh_listen_port_mode = input.meshListenPortMode || "auto";
|
||
scope.mesh_listen_auto_port_start = input.meshListenAutoPortStart || 19131;
|
||
scope.mesh_listen_auto_port_end = input.meshListenAutoPortEnd || 19231;
|
||
if (input.meshAdvertiseEndpoint?.trim()) {
|
||
scope.mesh_advertise_endpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "");
|
||
}
|
||
scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_http";
|
||
scope.mesh_connectivity_mode = input.meshConnectivityMode || "outbound_only";
|
||
scope.mesh_nat_type = input.meshNATType || "unknown";
|
||
scope.mesh_region = input.meshRegion || "windows";
|
||
}
|
||
if (input.installMode === "linux_binary") {
|
||
const endpoint = (input.controlPlaneEndpoint || defaultControlPlaneEndpoint()).trim().replace(/\/$/, "");
|
||
scope.install_profile = "linux_binary";
|
||
scope.backend_url = endpoint;
|
||
scope.control_plane_endpoints = [endpoint];
|
||
scope.artifact_endpoints = csvValues(input.artifactEndpoints || defaultArtifactEndpoint());
|
||
scope.startup_mode = "systemd";
|
||
if (input.linuxInstallDir.trim()) {
|
||
scope.install_dir = input.linuxInstallDir.trim();
|
||
}
|
||
if (input.linuxNodeAgentSHA256.trim()) {
|
||
scope.node_agent_artifact_sha256 = input.linuxNodeAgentSHA256.trim();
|
||
}
|
||
scope.replace = input.replace !== false;
|
||
scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime !== false;
|
||
scope.mesh_production_forwarding_enabled = false;
|
||
scope.mesh_listen_addr = input.meshListenAddr || ":19131";
|
||
scope.mesh_listen_port_mode = input.meshListenPortMode || "auto";
|
||
scope.mesh_listen_auto_port_start = input.meshListenAutoPortStart || 19131;
|
||
scope.mesh_listen_auto_port_end = input.meshListenAutoPortEnd || 19231;
|
||
if (input.meshAdvertiseEndpoint?.trim()) {
|
||
scope.mesh_advertise_endpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "");
|
||
}
|
||
scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_http";
|
||
scope.mesh_connectivity_mode = input.meshConnectivityMode || "outbound_only";
|
||
scope.mesh_nat_type = input.meshNATType || "unknown";
|
||
scope.mesh_region = input.meshRegion || "linux";
|
||
}
|
||
return scope;
|
||
}
|
||
|
||
function joinTokenFormFromScope(scope: Record<string, unknown>, fallback: JoinTokenFormState): JoinTokenFormState {
|
||
const roles = arrayStrings(scope.roles);
|
||
const artifactEndpoints = arrayStrings(scope.artifact_endpoints).join(", ");
|
||
return {
|
||
...fallback,
|
||
roles: roles.length > 0 ? roles : fallback.roles,
|
||
nodeName: stringField(scope, "node_name", "") || fallback.nodeName,
|
||
nodeGroupId: stringField(scope, "node_group_id", "") || fallback.nodeGroupId,
|
||
ownershipType: stringField(scope, "ownership_type", fallback.ownershipType),
|
||
purpose: stringField(scope, "purpose", "") || fallback.purpose,
|
||
installMode: stringField(scope, "install_profile", fallback.installMode),
|
||
dockerImage: stringField(scope, "image", fallback.dockerImage),
|
||
dockerContainerName: stringField(scope, "container_name", "") || fallback.dockerContainerName,
|
||
dockerNetwork: stringField(scope, "network", fallback.dockerNetwork),
|
||
windowsStartupMode: stringField(scope, "startup_mode", fallback.windowsStartupMode),
|
||
windowsInstallDir: stringField(scope, "install_dir", "") || fallback.windowsInstallDir,
|
||
windowsNodeAgentSHA256: stringField(scope, "node_agent_artifact_sha256", "") || fallback.windowsNodeAgentSHA256,
|
||
linuxInstallDir: stringField(scope, "install_dir", "") || fallback.linuxInstallDir,
|
||
linuxNodeAgentSHA256: stringField(scope, "node_agent_artifact_sha256", "") || fallback.linuxNodeAgentSHA256,
|
||
meshListenAddr: stringField(scope, "mesh_listen_addr", fallback.meshListenAddr),
|
||
meshListenPortMode: stringField(scope, "mesh_listen_port_mode", fallback.meshListenPortMode),
|
||
meshListenAutoPortStart: numberField(scope, "mesh_listen_auto_port_start", fallback.meshListenAutoPortStart),
|
||
meshListenAutoPortEnd: numberField(scope, "mesh_listen_auto_port_end", fallback.meshListenAutoPortEnd),
|
||
meshAdvertiseEndpoint: stringField(scope, "mesh_advertise_endpoint", "") || fallback.meshAdvertiseEndpoint,
|
||
meshAdvertiseTransport: stringField(scope, "mesh_advertise_transport", fallback.meshAdvertiseTransport),
|
||
meshConnectivityMode: stringField(scope, "mesh_connectivity_mode", fallback.meshConnectivityMode),
|
||
meshNATType: stringField(scope, "mesh_nat_type", fallback.meshNATType),
|
||
meshRegion: stringField(scope, "mesh_region", "") || fallback.meshRegion,
|
||
controlPlaneEndpoint: arrayStrings(scope.control_plane_endpoints)[0] || stringField(scope, "backend_url", "") || fallback.controlPlaneEndpoint,
|
||
artifactEndpoints: artifactEndpoints || fallback.artifactEndpoints,
|
||
dockerImageArtifactSHA256: stringField(scope, "docker_image_artifact_sha256", "") || fallback.dockerImageArtifactSHA256,
|
||
pullImage: booleanField(scope, "pull_image", fallback.pullImage),
|
||
replace: booleanField(scope, "replace", fallback.replace),
|
||
syntheticRuntime: booleanField(scope, "mesh_synthetic_runtime_enabled", fallback.syntheticRuntime),
|
||
};
|
||
}
|
||
|
||
function arrayStrings(value: unknown) {
|
||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
|
||
}
|
||
|
||
function booleanField(source: Record<string, unknown>, key: string, fallback: boolean) {
|
||
const value = source[key];
|
||
return typeof value === "boolean" ? value : fallback;
|
||
}
|
||
|
||
function defaultControlPlaneEndpoint() {
|
||
if (typeof window === "undefined" || !window.location?.origin) {
|
||
return "http://<control-plane-host>:18080/api/v1";
|
||
}
|
||
return `${window.location.origin.replace(/\/$/, "")}/api/v1`;
|
||
}
|
||
|
||
function defaultArtifactEndpoint() {
|
||
if (typeof window === "undefined" || !window.location?.origin) {
|
||
return "http://<bootstrap-host>:18080/downloads";
|
||
}
|
||
return `${window.location.origin.replace(/\/$/, "")}/downloads`;
|
||
}
|
||
|
||
function csvValues(value: string) {
|
||
return value
|
||
.split(",")
|
||
.map((item) => item.trim().replace(/\/$/, ""))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function dockerImageArtifactUrls(form: JoinTokenFormState) {
|
||
const fileName = "rap-node-agent-dev-enrollment-bootstrap-smoke.tar";
|
||
return csvValues(form.artifactEndpoints || defaultArtifactEndpoint()).map((endpoint) => `${endpoint}/${fileName}`);
|
||
}
|
||
|
||
function dockerConnectivityPreset(input: { meshConnectivityMode: string; meshNATType: string; meshAdvertiseEndpoint: string }) {
|
||
if (input.meshConnectivityMode === "outbound_only") {
|
||
return "outbound_only";
|
||
}
|
||
if (input.meshConnectivityMode === "private_lan") {
|
||
return "private_lan";
|
||
}
|
||
if (input.meshNATType !== "none" && input.meshAdvertiseEndpoint.trim()) {
|
||
return "nat_forward";
|
||
}
|
||
return "direct";
|
||
}
|
||
|
||
function applyDockerConnectivityPreset(input: JoinTokenFormState, mode: string): JoinTokenFormState {
|
||
const next = { ...input };
|
||
if (mode === "private_lan") {
|
||
next.meshConnectivityMode = "private_lan";
|
||
next.meshNATType = "none";
|
||
} else if (mode === "direct") {
|
||
next.meshConnectivityMode = "direct";
|
||
next.meshNATType = "none";
|
||
} else if (mode === "nat_forward") {
|
||
next.meshConnectivityMode = "direct";
|
||
next.meshNATType = "port_restricted";
|
||
} else {
|
||
next.meshConnectivityMode = "outbound_only";
|
||
next.meshNATType = "symmetric";
|
||
next.meshAdvertiseEndpoint = "";
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function suggestedInstallNodeName(form: Pick<JoinTokenFormState, "nodeName">, cluster: Cluster | null) {
|
||
if (form.nodeName.trim()) {
|
||
return form.nodeName.trim();
|
||
}
|
||
const slug = safeShellSlug(cluster?.slug || cluster?.name || "rap-node");
|
||
return `${slug}-node-1`;
|
||
}
|
||
|
||
function suggestedInstallContainerName(form: Pick<JoinTokenFormState, "nodeName" | "dockerContainerName">, cluster: Cluster | null) {
|
||
if (form.dockerContainerName.trim()) {
|
||
return form.dockerContainerName.trim();
|
||
}
|
||
return `rap-node-agent-${safeShellSlug(suggestedInstallNodeName(form, cluster))}`;
|
||
}
|
||
|
||
function hostAgentDockerInstallCommand(token: CreatedJoinToken, cluster: Cluster | null, form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const clusterID = cluster?.id || token.cluster_id;
|
||
const nodeName = suggestedInstallNodeName(form, cluster);
|
||
const containerName = suggestedInstallContainerName(form, cluster);
|
||
const slug = safeShellSlug(nodeName);
|
||
const endpoint = installControlPlaneEndpoint(form);
|
||
const command = [
|
||
"rap-host-agent install",
|
||
`--backend-url ${shellQuote(endpoint)}`,
|
||
`--cluster-id ${shellQuote(clusterID)}`,
|
||
`--join-token ${shellQuote(token.token)}`,
|
||
`--node-name ${shellQuote(nodeName)}`,
|
||
`--image ${shellQuote(form.dockerImage || "rap-node-agent:latest")}`,
|
||
`--container-name ${shellQuote(containerName)}`,
|
||
`--state-dir ${shellQuote(`/var/lib/rap/nodes/${slug}`)}`,
|
||
"--network host",
|
||
"--replace",
|
||
];
|
||
for (const artifactURL of dockerImageArtifactUrls(form)) {
|
||
command.push(`--image-artifact-url ${shellQuote(artifactURL)}`);
|
||
}
|
||
if (form.dockerImageArtifactSHA256.trim()) {
|
||
command.push(`--image-artifact-sha256 ${shellQuote(form.dockerImageArtifactSHA256.trim())}`);
|
||
}
|
||
return command.join(" \\\n ");
|
||
}
|
||
|
||
function hostAgentDockerProfileInstallCommand(token: CreatedJoinToken, cluster: Cluster | null, form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const clusterID = cluster?.id || token.cluster_id;
|
||
const nodeName = suggestedInstallNodeName(form, cluster);
|
||
const endpoint = installControlPlaneEndpoint(form);
|
||
const installCommand = [
|
||
'sudo "$rap_host_agent" install',
|
||
`--profile-url ${shellQuote(endpoint)}`,
|
||
`--cluster-id ${shellQuote(clusterID)}`,
|
||
`--install-token ${shellQuote(token.token)}`,
|
||
`--node-name ${shellQuote(nodeName)}`,
|
||
].join(" \\\n ");
|
||
return [
|
||
'rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"',
|
||
`curl -fL --retry 3 --retry-delay 1 ${shellQuote(hostAgentDownloadUrl(form))} -o "$rap_host_agent"`,
|
||
'chmod +x "$rap_host_agent"',
|
||
installCommand,
|
||
].join(" && \\\n ");
|
||
}
|
||
|
||
function hostAgentLinuxProfileInstallCommand(token: CreatedJoinToken, cluster: Cluster | null, form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const clusterID = cluster?.id || token.cluster_id;
|
||
const nodeName = suggestedInstallNodeName(form, cluster);
|
||
const endpoint = installControlPlaneEndpoint(form);
|
||
const installCommand = [
|
||
'sudo "$rap_host_agent" install-linux',
|
||
`--profile-url ${shellQuote(endpoint)}`,
|
||
`--cluster-id ${shellQuote(clusterID)}`,
|
||
`--install-token ${shellQuote(token.token)}`,
|
||
`--node-name ${shellQuote(nodeName)}`,
|
||
].join(" \\\n ");
|
||
return [
|
||
'rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"',
|
||
`curl -fL --retry 3 --retry-delay 1 ${shellQuote(hostAgentDownloadUrl(form))} -o "$rap_host_agent"`,
|
||
'chmod +x "$rap_host_agent"',
|
||
installCommand,
|
||
].join(" && \\\n ");
|
||
}
|
||
|
||
function hostAgentWindowsProfileInstallCommand(token: CreatedJoinToken, cluster: Cluster | null, form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const clusterID = cluster?.id || token.cluster_id;
|
||
const nodeName = suggestedInstallNodeName(form, cluster);
|
||
const endpoint = installControlPlaneEndpoint(form);
|
||
const hostAgentUrl = hostAgentWindowsDownloadUrl(form);
|
||
return [
|
||
`$rapHostAgent = Join-Path $env:TEMP "rap-host-agent.exe"`,
|
||
`Invoke-WebRequest -UseBasicParsing ${powershellQuote(hostAgentUrl)} -OutFile $rapHostAgent`,
|
||
`& $rapHostAgent install-windows --profile-url ${powershellQuote(endpoint)} --cluster-id ${powershellQuote(clusterID)} --install-token ${powershellQuote(token.token)} --node-name ${powershellQuote(nodeName)} --startup-mode ${powershellQuote(form.windowsStartupMode || "auto")}`,
|
||
].join("\r\n");
|
||
}
|
||
|
||
function hostAgentWindowsCmdProfileInstallCommand(token: CreatedJoinToken, cluster: Cluster | null, form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const clusterID = cluster?.id || token.cluster_id;
|
||
const nodeName = suggestedInstallNodeName(form, cluster);
|
||
const endpoint = installControlPlaneEndpoint(form);
|
||
const hostAgentUrl = hostAgentWindowsDownloadUrl(form);
|
||
const startupMode = form.windowsStartupMode || "auto";
|
||
return [
|
||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing '${hostAgentUrl}' -OutFile $env:TEMP\\rap-host-agent.exe"`,
|
||
`%TEMP%\\rap-host-agent.exe install-windows --profile-url "${endpoint}" --cluster-id "${clusterID}" --install-token "${token.token}" --node-name "${nodeName}" --startup-mode "${startupMode}"`,
|
||
].join("\r\n");
|
||
}
|
||
|
||
function windowsRepairUpdaterCmdCommand(node: ClusterNode, clusterID: string) {
|
||
const endpoint = externalPreferredControlPlaneEndpoint();
|
||
const origin = endpoint.replace(/\/api\/v1$/i, "").replace(/\/api$/i, "").replace(/\/$/, "");
|
||
const nodeName = node.name || node.node_key || node.id;
|
||
const nodeSlug = safeWindowsNodeSlug(nodeName);
|
||
const installDir = `%ProgramFiles%\\RAP\\${nodeSlug}`;
|
||
const stateDir = `%ProgramData%\\RAP\\nodes\\${nodeSlug}`;
|
||
const taskName = `RAP Node Agent ${nodeSlug}`;
|
||
const updaterTaskName = `RAP Host Agent Updater ${nodeSlug}`;
|
||
const hostAgentPath = `${installDir}\\rap-host-agent.exe`;
|
||
const stagedHostAgentPath = `${hostAgentPath}.next`;
|
||
return [
|
||
`@echo off`,
|
||
`echo === RAP Windows updater repair: ${cmdEscape(nodeName)} ===`,
|
||
`echo Node ID: ${node.id}`,
|
||
`echo Control Plane: ${endpoint}`,
|
||
`echo.`,
|
||
`echo === Before repair: scheduled tasks ===`,
|
||
`schtasks /Query /TN "${taskName}" /V /FO LIST`,
|
||
`schtasks /Query /TN "${updaterTaskName}" /V /FO LIST`,
|
||
`echo.`,
|
||
`echo === Before repair: binaries ===`,
|
||
`dir "${installDir}\\rap-*.exe*"`,
|
||
`echo.`,
|
||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing '${origin}/downloads/rap-host-agent-windows-amd64.exe' -OutFile $env:TEMP\\rap-host-agent.exe"`,
|
||
`%TEMP%\\rap-host-agent.exe install-windows --backend-url "${endpoint}" --cluster-id "${clusterID || "<cluster-id>"}" --node-id "${node.id}" --node-name "${cmdEscape(nodeName)}" --replace --startup-mode "auto" --auto-update-current-version "0.0.0" --auto-update-initial-delay-seconds 1`,
|
||
`"${hostAgentPath}" update-loop --backend-url "${endpoint}" --cluster-id "${clusterID || "<cluster-id>"}" --node-id "${node.id}" --state-dir "${stateDir}" --current-version "0.0.0" --os windows --arch amd64 --install-type windows_service --binary-path "${installDir}\\rap-node-agent.exe" --windows-task-name "${taskName}" --health-timeout-seconds 30 --interval-seconds 0 --initial-delay-seconds 0 --max-runs 1 --host-agent-update-status-enabled --host-agent-current-version "0.0.0" --host-agent-binary-path "${hostAgentPath}"`,
|
||
`echo.`,
|
||
`echo === Applying staged host-agent if present ===`,
|
||
`if exist "${stagedHostAgentPath}" copy /Y "${stagedHostAgentPath}" "${hostAgentPath}"`,
|
||
`if exist "${stagedHostAgentPath}" del /F /Q "${stagedHostAgentPath}"`,
|
||
`schtasks /End /TN "${updaterTaskName}"`,
|
||
`schtasks /Run /TN "${updaterTaskName}"`,
|
||
`echo.`,
|
||
`echo === After repair: binaries ===`,
|
||
`dir "${installDir}\\rap-*.exe*"`,
|
||
`echo.`,
|
||
`echo === After repair: updater task ===`,
|
||
`schtasks /Query /TN "${updaterTaskName}" /V /FO LIST`,
|
||
`echo.`,
|
||
`echo Repair command finished. Check the admin panel for rap-node-agent and rap-host-agent plan/noop reports.`,
|
||
].join("\r\n");
|
||
}
|
||
|
||
function windowsRepairUpdaterCmdFileName(node: ClusterNode) {
|
||
const nodeName = safeDownloadName(node.name || node.node_key || node.id || "node");
|
||
return `rap-repair-updater-${nodeName}.cmd`;
|
||
}
|
||
|
||
function linuxRepairUpdaterShCommand(node: ClusterNode, clusterID: string) {
|
||
const endpoint = externalPreferredControlPlaneEndpoint();
|
||
const origin = endpoint.replace(/\/api\/v1$/i, "").replace(/\/api$/i, "").replace(/\/$/, "");
|
||
const nodeName = node.name || node.node_key || node.id;
|
||
const nodeSlug = safeLinuxNodeSlug(nodeName);
|
||
const installDir = `/opt/rap/${nodeSlug}`;
|
||
const stateDir = `/var/lib/rap/nodes/${nodeSlug}`;
|
||
const unitName = `rap-node-agent-${nodeSlug}.service`;
|
||
const updaterUnitName = `rap-host-agent-updater-${nodeSlug}.service`;
|
||
const hostAgentPath = `${installDir}/rap-host-agent`;
|
||
return [
|
||
`#!/usr/bin/env bash`,
|
||
`set -euo pipefail`,
|
||
`echo "=== RAP Linux updater repair: ${shDoubleQuote(nodeName)} ==="`,
|
||
`echo "Node ID: ${node.id}"`,
|
||
`echo "Control Plane: ${endpoint}"`,
|
||
`echo`,
|
||
`echo "=== Before repair: systemd units ==="`,
|
||
`systemctl status ${shellQuote(unitName)} --no-pager || true`,
|
||
`systemctl status ${shellQuote(updaterUnitName)} --no-pager || true`,
|
||
`echo`,
|
||
`echo "=== Before repair: binaries ==="`,
|
||
`ls -la ${shellQuote(installDir)} || true`,
|
||
`echo`,
|
||
`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,
|
||
`curl -fL --retry 3 --retry-delay 1 ${shellQuote(`${origin}/downloads/rap-host-agent-linux-amd64`)} -o "$rap_host_agent"`,
|
||
`chmod +x "$rap_host_agent"`,
|
||
`sudo "$rap_host_agent" install-linux --backend-url ${shellQuote(endpoint)} --cluster-id ${shellQuote(clusterID || "<cluster-id>")} --node-id ${shellQuote(node.id)} --node-name ${shellQuote(nodeName)} --replace --startup-mode systemd --auto-update-current-version 0.0.0 --auto-update-initial-delay-seconds 1`,
|
||
`sudo ${shellQuote(hostAgentPath)} update-loop --backend-url ${shellQuote(endpoint)} --cluster-id ${shellQuote(clusterID || "<cluster-id>")} --node-id ${shellQuote(node.id)} --state-dir ${shellQuote(stateDir)} --current-version 0.0.0 --os linux --arch amd64 --install-type linux_binary --binary-path ${shellQuote(`${installDir}/rap-node-agent`)} --systemd-unit ${shellQuote(unitName)} --health-timeout-seconds 30 --interval-seconds 0 --initial-delay-seconds 0 --max-runs 1 --host-agent-update-status-enabled --host-agent-current-version 0.0.0 --host-agent-binary-path ${shellQuote(hostAgentPath)}`,
|
||
`sudo systemctl daemon-reload`,
|
||
`sudo systemctl restart ${shellQuote(updaterUnitName)}`,
|
||
`echo`,
|
||
`echo "=== After repair: systemd updater ==="`,
|
||
`systemctl status ${shellQuote(updaterUnitName)} --no-pager || true`,
|
||
`echo "Repair command finished. Check the admin panel for rap-node-agent and rap-host-agent plan/noop reports."`,
|
||
].join("\n");
|
||
}
|
||
|
||
function linuxRepairUpdaterShFileName(node: ClusterNode) {
|
||
const nodeName = safeDownloadName(node.name || node.node_key || node.id || "node");
|
||
return `rap-repair-updater-${nodeName}.sh`;
|
||
}
|
||
|
||
function downloadTextFile(fileName: string, content: string) {
|
||
if (typeof document === "undefined") {
|
||
return;
|
||
}
|
||
const blob = new Blob([content.endsWith("\r\n") ? content : `${content}\r\n`], { type: "text/plain;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = fileName;
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function copyTextToClipboard(content: string) {
|
||
await navigator.clipboard.writeText(content);
|
||
}
|
||
|
||
function safeDownloadName(value: string) {
|
||
return value
|
||
.trim()
|
||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.replace(/^-|-$/g, "")
|
||
.slice(0, 80) || "node";
|
||
}
|
||
|
||
function safeWindowsNodeSlug(value: string) {
|
||
return value
|
||
.trim()
|
||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.replace(/^-|-$/g, "") || "node";
|
||
}
|
||
|
||
function safeLinuxNodeSlug(value: string) {
|
||
return safeWindowsNodeSlug(value).slice(0, 48) || "node";
|
||
}
|
||
|
||
function shDoubleQuote(value: string) {
|
||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
||
}
|
||
|
||
function installControlPlaneEndpoint(form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
return (form.controlPlaneEndpoint || defaultControlPlaneEndpoint()).trim().replace(/\/$/, "");
|
||
}
|
||
|
||
function externalPreferredControlPlaneEndpoint() {
|
||
const origin = typeof window === "undefined" ? "" : window.location?.origin || "";
|
||
if (/^(http:\/\/)?(192\.168\.200\.61|docker-test\.cin\.su)(:18080)?$/i.test(origin.replace(/\/$/, ""))) {
|
||
return "https://vpn.cin.su/api/v1";
|
||
}
|
||
return `${origin.replace(/\/$/, "")}/api/v1`;
|
||
}
|
||
|
||
function installArtifactOrigin(form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const firstArtifactEndpoint = csvValues(form.artifactEndpoints)[0];
|
||
if (firstArtifactEndpoint) {
|
||
return firstArtifactEndpoint.replace(/\/downloads$/i, "").replace(/\/$/, "");
|
||
}
|
||
const endpoint = installControlPlaneEndpoint(form);
|
||
return endpoint.replace(/\/api\/v1$/i, "").replace(/\/api$/i, "").replace(/\/$/, "");
|
||
}
|
||
|
||
function hostAgentDownloadUrl(form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const origin =
|
||
typeof window === "undefined" && !form.controlPlaneEndpoint
|
||
? "http://<control-plane-host>:18080"
|
||
: installArtifactOrigin(form);
|
||
return `${origin}/downloads/rap-host-agent-linux-amd64`;
|
||
}
|
||
|
||
function hostAgentWindowsDownloadUrl(form: JoinTokenFormState = defaultJoinTokenForm) {
|
||
const origin =
|
||
typeof window === "undefined" && !form.controlPlaneEndpoint
|
||
? "http://<control-plane-host>:18080"
|
||
: installArtifactOrigin(form);
|
||
return `${origin}/downloads/rap-host-agent-windows-amd64.exe`;
|
||
}
|
||
|
||
function safeShellSlug(value: string) {
|
||
return (
|
||
value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 42) || "rap-node"
|
||
);
|
||
}
|
||
|
||
function shellQuote(value: string) {
|
||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||
}
|
||
|
||
function powershellQuote(value: string) {
|
||
return `'${value.replace(/'/g, "''")}'`;
|
||
}
|
||
|
||
function cmdEscape(value: string) {
|
||
return value.replace(/"/g, `""`);
|
||
}
|
||
|
||
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";
|
||
}
|
||
if (isHeartbeatStale(heartbeat)) {
|
||
return "stale";
|
||
}
|
||
const capabilities = heartbeat.capabilities || {};
|
||
return expectedKeys.some((key) => Boolean(capabilities[key])) ? "confirmed" : "missing";
|
||
}
|
||
|
||
function isHeartbeatStale(heartbeat?: NodeHeartbeat) {
|
||
if (!heartbeat?.observed_at) {
|
||
return true;
|
||
}
|
||
const observedAt = new Date(heartbeat.observed_at).getTime();
|
||
return !Number.isFinite(observedAt) || Date.now() - observedAt > 60 * 1000;
|
||
}
|
||
|
||
function capabilityPillTone(role: string, heartbeat?: NodeHeartbeat) {
|
||
const state = capabilityState(role, heartbeat);
|
||
if (state === "confirmed") {
|
||
return "good";
|
||
}
|
||
if (state === "missing") {
|
||
return "bad";
|
||
}
|
||
if (state === "stale") {
|
||
return "warn";
|
||
}
|
||
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;
|
||
}
|
||
if (state === "stale") {
|
||
return "heartbeat устарел";
|
||
}
|
||
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" : state === "stale" ? "stale heartbeat" : "unknown";
|
||
}
|
||
return state === "confirmed" ? "подходит" : state === "missing" ? "не заявлено" : state === "stale" ? "heartbeat устарел" : "неизвестно";
|
||
}
|
||
|
||
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 degraded = typeof fields.degraded_decision_count === "number" ? fields.degraded_decision_count : 0;
|
||
const recoveryHysteresis = typeof fields.recovery_hysteresis_count === "number" ? fields.recovery_hysteresis_count : 0;
|
||
const recoveryPromoted = typeof fields.recovery_promoted_count === "number" ? fields.recovery_promoted_count : 0;
|
||
const recoveryDemoted = typeof fields.recovery_demoted_count === "number" ? fields.recovery_demoted_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 (degraded > 0) {
|
||
parts.push(`degr${degraded}`);
|
||
}
|
||
if (recoveryHysteresis > 0) {
|
||
parts.push(`rec${recoveryHysteresis}`);
|
||
}
|
||
if (recoveryPromoted > 0) {
|
||
parts.push(`prom${recoveryPromoted}`);
|
||
}
|
||
if (recoveryDemoted > 0) {
|
||
parts.push(`dem${recoveryDemoted}`);
|
||
}
|
||
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 meshListenerSummary(heartbeat?: NodeHeartbeat) {
|
||
const report = heartbeat?.metadata?.mesh_listener_report;
|
||
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||
return "н/д";
|
||
}
|
||
const fields = report as Record<string, unknown>;
|
||
const status = typeof fields.status === "string" ? fields.status : "unknown";
|
||
const mode = typeof fields.listen_port_mode === "string" ? fields.listen_port_mode : "manual";
|
||
const effective = typeof fields.effective_listen_addr === "string" ? fields.effective_listen_addr : "";
|
||
const reason = typeof fields.failure_reason === "string" ? fields.failure_reason : "";
|
||
if (status === "listening") {
|
||
return effective ? `listen ${effective}` : "listen";
|
||
}
|
||
if (status === "auto_rebound") {
|
||
return effective ? `auto ${effective}` : "auto rebound";
|
||
}
|
||
if (status === "listen_failed") {
|
||
return reason ? `${mode} failed: ${reason}` : `${mode} failed`;
|
||
}
|
||
if (status === "disabled") {
|
||
return mode === "disabled" ? "inbound off" : "inbound unavailable";
|
||
}
|
||
return status;
|
||
}
|
||
|
||
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, status: TopologyLinkStatus = link.link_status === "reachable" ? "reachable" : "unknown") {
|
||
if (status === "stale") {
|
||
return "stale";
|
||
}
|
||
if (status === "one_way") {
|
||
return "oneWay";
|
||
}
|
||
if (status !== "reachable" || 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, maxLength = 16) {
|
||
return value.length > maxLength ? `${value.slice(0, Math.max(1, maxLength - 2))}…` : value;
|
||
}
|
||
|
||
function confirmHighRisk(action: string) {
|
||
return window.confirm(`${action}?\n\nЭто высокорисковая операция владельца платформы. Действие будет записано в аудит.`);
|
||
}
|
||
|
||
function downloadBaseUrl(apiBaseUrl: string) {
|
||
const normalized = (apiBaseUrl || "").replace(/\/$/, "");
|
||
if (!normalized || normalized === "/api/v1") {
|
||
return window.location.origin;
|
||
}
|
||
if (normalized.endsWith("/api/v1")) {
|
||
return normalized.slice(0, -"/api/v1".length);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
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 formatAgeSeconds(value?: number | null) {
|
||
if (value == null || Number.isNaN(value)) {
|
||
return "age n/a";
|
||
}
|
||
if (value < 60) {
|
||
return `${Math.max(0, Math.round(value))}s ago`;
|
||
}
|
||
if (value < 3600) {
|
||
return `${Math.round(value / 60)}m ago`;
|
||
}
|
||
if (value < 86400) {
|
||
return `${Math.round(value / 3600)}h ago`;
|
||
}
|
||
return `${Math.round(value / 86400)}d ago`;
|
||
}
|
||
|
||
function formatTime(value?: string | null) {
|
||
if (!value) {
|
||
return "н/д";
|
||
}
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
}).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 formatTrafficClassCounts(value?: Record<string, number> | null) {
|
||
if (!value || Object.keys(value).length === 0) {
|
||
return "qos none";
|
||
}
|
||
return ["control", "interactive", "reliable", "bulk", "droppable"]
|
||
.filter((key) => (value[key] || 0) > 0)
|
||
.map((key) => `${key[0]}:${value[key]}`)
|
||
.join(" ") || "qos none";
|
||
}
|
||
|
||
function formatRecommendedWindows(value?: Record<string, number> | null) {
|
||
if (!value || Object.keys(value).length === 0) {
|
||
return "n/a";
|
||
}
|
||
return ["control", "interactive", "reliable", "bulk", "droppable"]
|
||
.filter((key) => (value[key] || 0) > 0)
|
||
.map((key) => `${key[0]}:${value[key]}`)
|
||
.join(" ") || "n/a";
|
||
}
|
||
|
||
function flowHealthPillClass(status?: string | null, dropped?: number | null) {
|
||
if ((dropped || 0) > 0 || status === "critical") {
|
||
return "bad";
|
||
}
|
||
if (status === "degraded") {
|
||
return "warn";
|
||
}
|
||
if (status === "watch") {
|
||
return "info";
|
||
}
|
||
return "good";
|
||
}
|
||
|
||
function remediationExecutionTone(status?: string) {
|
||
switch (status) {
|
||
case "applied":
|
||
case "rebuild_request_applied":
|
||
return "good";
|
||
case "waiting_node_apply":
|
||
case "pending_rebuild_request":
|
||
case "pending_degraded_fallback":
|
||
case "rebuild_request_recorded":
|
||
case "rebuild_request_recorded_node_pending":
|
||
case "rebuild_request_no_alternate":
|
||
case "rebuild_request_deferred_by_policy":
|
||
case "route_rebuild_no_safe_recovery":
|
||
return "warn";
|
||
case "expired":
|
||
case "rejected_by_policy_guard":
|
||
case "rebuild_request_rejected":
|
||
case "rebuild_request_expired":
|
||
return "bad";
|
||
default:
|
||
return status ? "bad" : "";
|
||
}
|
||
}
|
||
|
||
function routeDecisionTone(source?: string, status?: string, reasons?: string[]) {
|
||
if (source === "service_channel_feedback_no_alternate" || status === "pending_degraded_fallback" || (reasons || []).includes("no_unfenced_alternate_route")) {
|
||
return "warn";
|
||
}
|
||
if (status === "applied" || (reasons || []).includes("service_channel_rebuild_applied")) {
|
||
return "good";
|
||
}
|
||
if (source?.includes("replacement")) {
|
||
return "info";
|
||
}
|
||
return source || status ? "info" : "";
|
||
}
|
||
|
||
function statusLabel(value: string) {
|
||
const labels: Record<string, string> = {
|
||
active: "активно",
|
||
approved: "одобрено",
|
||
authoritative: "authoritative",
|
||
connecting: "подключается",
|
||
connected: "связан",
|
||
critical: "критично",
|
||
current: "актуальна",
|
||
degraded: "degraded",
|
||
disabled: "выключено",
|
||
enabled: "включено",
|
||
failed: "ошибка",
|
||
healthy: "здоров",
|
||
watch: "наблюдение",
|
||
flow_health_ready: "flow ready",
|
||
flow_drops_reported: "flow drops",
|
||
route_quality_window_drops_reported: "route drops",
|
||
backend_fallback_observed: "backend fallback",
|
||
route_quality_window_failures_reported: "route failures",
|
||
route_quality_window_slow_samples_reported: "slow samples",
|
||
route_send_latency_high: "high latency",
|
||
flow_queue_pressure_high: "queue pressure high",
|
||
bulk_pressure_with_interactive_qos_observed: "bulk+interactive",
|
||
bulk_pressure_observed: "bulk pressure",
|
||
flow_queue_pressure_observed: "queue pressure",
|
||
flow_health_degraded: "flow degraded",
|
||
bulk_window_reduced_to_protect_interactive: "bulk reduced",
|
||
rebuild_request_applied: "planner applied",
|
||
rebuild_request_recorded: "rebuild recorded",
|
||
rebuild_request_recorded_node_pending: "node pending",
|
||
rebuild_request_no_alternate: "no alternate",
|
||
rebuild_request_deferred_by_policy: "deferred by policy",
|
||
rebuild_request_rejected: "rebuild rejected",
|
||
rebuild_request_expired: "rebuild expired",
|
||
route_rebuild_no_safe_recovery: "no safe recovery",
|
||
access_decision: "access decision",
|
||
access_no_safe_recovery: "access no-safe",
|
||
access_recovery_selected: "access recovery",
|
||
access_rebuild_applied: "access applied",
|
||
access_replacement_selected: "access replacement",
|
||
inspect_access_no_safe_recovery_route_pool_and_signed_policy: "inspect no-safe route pool",
|
||
watch_recovery_route_quality_and_confirm_post_recovery_traffic: "watch recovery traffic",
|
||
confirm_applied_rebuild_runtime_traffic_stays_on_replacement: "confirm applied traffic",
|
||
watch_replacement_route_quality_until_applied_or_recovered: "watch replacement",
|
||
pending_degraded_fallback: "pending fallback",
|
||
service_channel_feedback_no_alternate: "no safe route",
|
||
service_channel_feedback_replacement: "replacement",
|
||
service_channel_feedback_exit_pool_replacement: "exit replacement",
|
||
service_channel_feedback_entry_pool_replacement: "entry replacement",
|
||
service_channel_feedback_entry_exit_pool_replacement: "pool replacement",
|
||
service_channel_remediation_command: "remediation",
|
||
service_channel_feedback_rebuild_requested: "rebuild requested",
|
||
remediation_rebuild_applied_to_alternate: "planner selected alternate",
|
||
no_unfenced_alternate_route: "no safe alternate",
|
||
active_lease_not_found_for_rebuild_resolution: "lease missing",
|
||
remediation_command_ttl_expired: "command expired",
|
||
durable_rebuild_route_request_recorded: "rebuild recorded",
|
||
durable_rebuild_route_request_rejected: "request rejected",
|
||
durable_rebuild_route_request_applied: "request applied",
|
||
durable_rebuild_route_no_alternate: "no alternate",
|
||
durable_rebuild_route_deferred_by_policy: "deferred by policy",
|
||
durable_rebuild_route_expired: "request expired",
|
||
isolated: "изолирован",
|
||
offline: "нет связи",
|
||
one_way: "односторонняя",
|
||
outdated: "обновить",
|
||
pending: "ожидает",
|
||
platform_managed: "платформенный",
|
||
promoted: "promoted",
|
||
rejected: "отклонено",
|
||
ready: "готово",
|
||
revoked: "отозвано",
|
||
running: "работает",
|
||
customer_managed: "клиентский",
|
||
no_policy: "нет политики",
|
||
not_configured: "не задано",
|
||
missing: "нет отчета",
|
||
service_channel_recovery_demoted: "demoted",
|
||
service_channel_recovery_demoted_degraded: "degraded",
|
||
service_channel_recovery_demoted_degraded_fallback: "fallback",
|
||
service_channel_recovery_demoted_failure: "failure",
|
||
service_channel_recovery_demoted_fenced: "fenced",
|
||
service_channel_recovery_demoted_rebuild: "rebuild",
|
||
service_channel_recovery_demoted_slow: "slow",
|
||
service_channel_feedback_provenance_missing: "provenance missing",
|
||
service_channel_feedback_stale: "stale feedback",
|
||
service_channel_feedback_stale_generation: "stale generation",
|
||
service_channel_feedback_stale_policy: "stale policy",
|
||
service_channel_feedback_stale_policy_and_generation: "stale policy+generation",
|
||
schema_ready: "schema ready",
|
||
schema_migration_required: "schema migration required",
|
||
snapshots_warmed: "snapshots warmed",
|
||
missing_snapshots_warmed_stale_deferred: "missing warmed, stale deferred",
|
||
snapshot_warmup_partial: "warmup partial",
|
||
stopped: "остановлено",
|
||
stale: "устарело",
|
||
unknown: "неизвестно",
|
||
};
|
||
return labels[value] || value;
|
||
}
|