Files
rdp-proxy/web-admin/src/App.tsx
T
2026-04-28 22:29:50 +03:00

4536 lines
214 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AdminApiClient } from "./api/client";
import type {
AuditEvent,
AuthResult,
Cluster,
ClusterAdminSummary,
ClusterAuthorityState,
ClusterNode,
ClusterNodeGroup,
CreatedJoinToken,
FabricEntryPoint,
FabricEntryPointNode,
FabricEgressPool,
FabricEgressPoolNode,
FabricTestingFlag,
InstallationStatus,
JoinRequest,
MeshLink,
NodeHeartbeat,
NodeSyntheticMeshConfig,
NodeTelemetryObservation,
NodeWorkloadDesiredState,
OrganizationAdminSummary,
QoSPolicy,
RoleAssignment,
VPNConnection,
VPNConnectionLease,
WorkloadStatus,
} from "./types";
const storageKeys = {
baseUrl: "rap.webAdmin.baseUrl",
actorUserId: "rap.webAdmin.actorUserId",
auth: "rap.webAdmin.auth",
language: "rap.webAdmin.language",
};
const defaultBaseUrl = "/api/v1";
const legacyDirectBackendPrefix = "http://192.168.200.61:8080/api/v1";
const roleOptions = [
"entry-node",
"relay-node",
"core-mesh",
"rdp-worker",
"vnc-worker",
"vpn-exit",
"vpn-connector",
"file-storage-cache",
"update-cache",
"video-relay",
];
const capabilityKeysByRole: Record<string, string[]> = {
"entry-node": ["can_accept_client_ingress"],
"relay-node": ["can_route_mesh", "can_accept_node_ingress"],
"core-mesh": ["can_route_mesh", "can_accept_node_ingress"],
"rdp-worker": ["can_run_rdp_worker"],
"vnc-worker": ["can_run_vnc_worker"],
"vpn-exit": ["can_run_vpn_exit"],
"vpn-connector": ["can_run_vpn_connector"],
"file-storage-cache": ["can_run_file_cache"],
"update-cache": ["can_run_update_cache"],
"video-relay": ["can_run_video_relay"],
};
const views = [
{ id: "command", ru: "Обзор", en: "Command" },
{ id: "clusters", ru: "Кластеры", en: "Clusters" },
{ id: "cluster-settings", ru: "Настройки кластера", en: "Cluster Settings" },
{ id: "nodes", ru: "Узлы", en: "Nodes" },
{ id: "enrollment", ru: "Подключение узлов", en: "Enrollment" },
{ id: "roles", ru: "Роли", en: "Roles" },
{ id: "workloads", ru: "Сервисы", en: "Workloads" },
{ id: "fabric", ru: "Связи Fabric", en: "Fabric Links" },
{ id: "vpn", ru: "VPN Control", en: "VPN Control" },
{ id: "org-safe", ru: "Организации", en: "Organizations" },
{ id: "audit", ru: "Аудит", en: "Audit" },
] as const;
type ViewId = (typeof views)[number]["id"];
type Language = "ru" | "en";
type NodeInventoryEntry = {
node: ClusterNode;
memberships: Array<{ cluster: Cluster; node: ClusterNode }>;
};
type NodeInventoryTreeRow =
| { kind: "group"; key: string; label: string; depth: number; count: number; groupId?: string }
| { kind: "node"; key: string; entry: NodeInventoryEntry; depth: number };
type WebAdminSession = {
userId: string;
email: string;
authSessionId: string;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: string;
refreshTokenExpiresAt: string;
};
const copy = {
ru: {
productOwner: "Владелец продукта",
controlPlane: "Панель управления",
sideText: "Главная панель владельца платформы для кластеров, узлов, доверия, ролей и безопасного desired state.",
signInTitle: "Вход",
signInText: "Введите учетные данные.",
bootstrapTitle: "Первый владелец",
bootstrapText: "Пустая установка принимает только подписанную активацию продукта.",
activationPayload: "Activation manifest JSON",
activationSignature: "Подпись manifest",
createOwner: "Создать владельца",
creatingOwner: "Создание...",
ownerCreated: "Владелец создан. Теперь можно войти.",
installationLocked: "Установка уже активирована",
insecureBootstrapDisabled: "Insecure bootstrap выключен. Нужна strict-активация с ключом продукта.",
email: "Логин",
password: "Пароль",
backendApi: "Backend API",
useLocalProxy: "Использовать локальный /api/v1 proxy",
language: "Язык",
deviceLabel: "Устройство",
trustDevice: "Доверять этому устройству",
signIn: "Войти",
signingIn: "Вход...",
logout: "Выйти",
profile: "Профиль",
refresh: "Обновить",
refreshing: "Обновление...",
activeCluster: "Активный кластер",
slugLabel: "Технический код",
slugHelp:
"Slug — постоянный короткий технический идентификатор кластера для URL, скриптов, логов и интеграций. Его лучше не менять после создания.",
clusterCatalog: "Каталог кластеров",
clusterCatalogText: "Список реальных кластеров из Control Plane. Выберите активный кластер или раскройте карточку для подробностей.",
makeActive: "Сделать активным",
openSettings: "Открыть настройки",
selected: "Выбран",
createCluster: "Создать кластер",
clusterDetails: "Подробнее",
consoleTitle: "Панель владельца платформы",
boundary:
"WEB является только представлением. Решения кластера проходят через Control Plane API, PostgreSQL как source of truth и аудит.",
noLoginError: "Войдите как владелец продукта или администратор платформы, чтобы загрузить панель.",
accessDenied: "Доступ к этой панели запрещен.",
emptyLiveTitle: "Кластер пока пустой",
emptyLiveText:
"Это реальные данные, не заглушка: в выбранном кластере ещё нет одобренных node-agent узлов. Создайте join token, запустите rap-node-agent и подтвердите join request.",
realDataNote:
"Показываются только данные из PostgreSQL/Control Plane. Если значения нулевые, значит соответствующих узлов, ролей или сервисов пока нет.",
signedInAs: "Вход выполнен",
actorUser: "Actor user",
testMode: "Тестирование",
testModeText: "Включает тестовую телеметрию и синтетические наблюдения связей. Это не production mesh runtime.",
platformTestFlag: "Тестирование сервера",
nodeTelemetry: "Телеметрия узла",
heartbeatHistory: "История heartbeat",
noTelemetry: "Телеметрии пока нет",
enableTelemetry: "Включить телеметрию",
enableSyntheticLinks: "Включить тестовые связи",
saveTestFlag: "Сохранить флаг",
nodeManagement: "Управление узлом",
nodeScope: "Область просмотра",
currentClusterNodes: "Узлы активного кластера",
allNodes: "Все узлы платформы",
showAllPlatformNodes: "Показать все узлы платформы",
currentClusterMembership: "Участие в активном кластере",
clusterMemberships: "Участие по кластерам",
notMemberOfActiveCluster: "не состоит",
nodeIdentity: "Физическая идентичность узла",
activeClusterScope: "Область активного кластера",
activeClusterScopeText:
"Один физический узел может состоять в нескольких кластерах. Роли и desired-сервисы ниже относятся только к выбранному активному кластеру.",
capabilityConfirmed: "способность подтверждена heartbeat",
capabilityMissing: "способность не заявлена узлом",
capabilityUnknown: "способность не подтверждена: нет heartbeat",
nodeGlobalInventoryText:
"Один физический узел показан один раз. Участие и роли остаются кластерными: в разных кластерах этот же узел может иметь разные назначения.",
nodeSearch: "Поиск узлов",
groupNodesBy: "Группировать",
groupByMembership: "по участию",
groupByHealth: "по здоровью",
groupByOwnership: "по владению",
groupByClusterCount: "по числу кластеров",
nodeGroups: "Группы узлов",
nodeGroupTree: "Дерево групп",
nodeGroupFilter: "Фильтр по группе",
allNodeGroups: "Все группы",
nodeGroupCreatePanel: "Создание группы",
nodeGroupName: "Название группы",
parentNodeGroup: "Родительская группа",
rootNodeGroup: "Корень",
ungroupedNodes: "Без группы",
createNodeGroup: "Создать группу",
createSubgroup: "Создать подгруппу",
collapseGroup: "Свернуть",
expandGroup: "Развернуть",
assignNodeGroup: "Переместить в группу",
removeFromNodeGroup: "Убрать из группы",
connectExistingNode: "Подключить к активному кластеру",
connectExistingNodeTitle: "Подключить существующий узел",
connectExistingNodeText:
"Будет создано или повторно включено участие конкретного физического узла в активном кластере. Роли ниже назначаются только в этом кластере.",
connectWithRoles: "Подключить с ролями",
nodeDetails: "Сведения",
manageNode: "Настроить",
nodeFunctions: "Функции узла",
nodeFunctionsText:
"Одна строка управляет функцией целиком: роль задает разрешение в активном кластере, desired-сервис задает запуск, observed показывает факт от node-agent.",
rolePermission: "Разрешение",
permissionGranted: "разрешено",
permissionDenied: "нет разрешения",
organizationScopeForEnable: "Область организации для новых включений, опционально",
clusterWideRolePlaceholder: "пусто = роль на весь кластер",
desiredRuntime: "Желаемое состояние",
observedRuntime: "Фактически",
enableFunction: "Включить функцию",
disableFunction: "Выключить функцию",
close: "Закрыть",
nodeBriefList: "Краткий список узлов",
noActiveClusterMembership: "Узел не входит в активный кластер",
nodeBriefListHelp:
"Список сгруппирован деревом активного кластера. Полные сведения, управление, роли, сервисы и статистика открываются из строки узла.",
nodeSearchPlaceholder: "имя, ключ, кластер, статус",
nodeGroupInventoryText:
"Группы — это кластерная инвентарная структура. Перенос узла в группу меняет только его размещение внутри активного кластера, не роли и не членство.",
nodeGroupCreated: "Группа узлов создана.",
noNodesTitle: "Нет узлов",
noNodesByFilter: "По текущему фильтру узлы не найдены.",
cancel: "Отмена",
alreadyMember: "Уже в активном кластере",
revokedMembership: "Участие отозвано",
addNode: "Подключить узел",
addNodeText:
"Подключение существующего физического узла к активному кластеру выполняется из списка узлов: включите общий режим и нажмите «Подключить к активному кластеру».",
joinTokenTitle: "Создать join token",
joinTokenText:
"Join token — временный пропуск для rap-node-agent. Он не создает узел напрямую: агент отправляет заявку, а владелец платформы подтверждает ее.",
ttlHours: "Срок действия, часов",
ttlHelp: "Через это время token станет недействительным, даже если им никто не воспользовался. Для ручного подключения обычно достаточно 1–24 часов.",
maxUses: "Максимум использований",
maxUsesHelp: "Сколько node-agent смогут использовать этот token. Самый безопасный вариант — 1 token на 1 новый узел.",
tokenPurpose: "Назначение token",
nodeOwnership: "Тип владения узлом",
suggestedRoles: "Разрешенные/ожидаемые роли",
generatedScope: "Сгенерированная область действия",
generatedScopeHelp:
"JSON формируется автоматически из настроек выше. Оператор не должен писать его руками, чтобы не ошибиться синтаксисом или областью доступа.",
manualApprovalRequired: "Подтверждение заявки вручную обязательно",
nodeRoles: "Роли узла",
desiredServices: "Желаемые сервисы",
observedServices: "Наблюдаемые сервисы",
noRoles: "Ролей пока нет",
noServices: "Сервисов пока нет",
manageInCluster: "Управлять в кластере",
rolesAndServices: "Роли и сервисы",
links: "Связи",
fabricMap: "Карта трафика Fabric",
fabricIngressLayer: "Входы",
fabricNodeLayer: "Узлы кластера",
fabricEgressLayer: "Выходные зоны",
observedPeerLinks: "Наблюдаемые связи",
placementIntent: "control-plane назначение",
fabricEntryPoints: "Точки входа",
fabricEntryPointHelp: "Логические внешние входы в кластер. Они скрывают конкретные узлы от организаций и клиентов.",
fabricEgressPools: "Выходные зоны",
fabricEgressPoolHelp: "Логические выходы к внешним сетям, например “Офис Москва”. Организации используют зону, а не конкретный узел.",
endpointName: "Название",
publicEndpoint: "Публичный адрес",
endpointType: "Тип входа",
description: "Описание",
routeScope: "Область маршрутов JSON",
createEntryPoint: "Создать точку входа",
createEgressPool: "Создать выходную зону",
endpointNodes: "Назначенные узлы",
assignEndpointNode: "Назначить узел",
selectNode: "Выберите узел",
assignedNodesEmpty: "Узлы пока не назначены",
entryPointsEmpty: "Точки входа пока не созданы.",
egressPoolsEmpty: "Выходные зоны пока не созданы.",
addressNotSet: "адрес не задан",
descriptionNotSet: "описание не задано",
servicePlacement: "Размещение сервисов",
trafficFlow: "Потоки между узлами",
organizationTestFlag: "Тестирование организации",
organizationId: "ID организации",
saveOrganizationFlag: "Сохранить флаг организации",
noLinks: "Связей пока нет",
recentHeartbeats: "Последние heartbeat",
memory: "Память",
cpu: "Процессор",
processes: "Процессы",
},
en: {
productOwner: "Product Owner",
controlPlane: "Control Plane",
sideText: "Full platform-owner panel for clusters, nodes, trust, placement, and safe service desired state.",
signInTitle: "Sign in",
signInText: "Enter your credentials.",
bootstrapTitle: "First owner",
bootstrapText: "An empty installation accepts only a signed product activation.",
activationPayload: "Activation manifest JSON",
activationSignature: "Manifest signature",
createOwner: "Create owner",
creatingOwner: "Creating...",
ownerCreated: "Owner created. You can sign in now.",
installationLocked: "Installation is already active",
insecureBootstrapDisabled: "Insecure bootstrap is disabled. Strict product-key activation is required.",
email: "Login",
password: "Password",
backendApi: "Backend API",
useLocalProxy: "Use local /api/v1 proxy",
language: "Language",
deviceLabel: "Device",
trustDevice: "Trust this device",
signIn: "Sign in",
signingIn: "Signing in...",
logout: "Logout",
profile: "Profile",
refresh: "Refresh",
refreshing: "Refreshing...",
activeCluster: "Active cluster",
slugLabel: "Technical code",
slugHelp:
"Slug is a stable short technical identifier for URLs, scripts, logs, and integrations. It should generally not change after creation.",
clusterCatalog: "Cluster catalog",
clusterCatalogText: "Real clusters from the Control Plane. Select the active cluster or expand a card for details.",
makeActive: "Make active",
openSettings: "Open settings",
selected: "Selected",
createCluster: "Create cluster",
clusterDetails: "Details",
consoleTitle: "Platform Owner Console",
boundary: "WEB is presentation only. Cluster decisions go through Control Plane APIs, PostgreSQL source of truth, and audit.",
noLoginError: "Sign in as a product owner or platform administrator to load the panel.",
accessDenied: "Access to this panel is denied.",
emptyLiveTitle: "Cluster has no live nodes yet",
emptyLiveText:
"These are real values, not placeholders: the selected cluster has no approved node-agent nodes yet. Create a join token, run rap-node-agent, and approve the join request.",
realDataNote: "Only PostgreSQL/Control Plane data is shown. Zero values mean the corresponding nodes, roles, or services do not exist yet.",
signedInAs: "Signed in",
actorUser: "Actor user",
testMode: "Testing",
testModeText: "Enables test telemetry and synthetic link observations. This is not production mesh runtime.",
platformTestFlag: "Server testing",
nodeTelemetry: "Node telemetry",
heartbeatHistory: "Heartbeat history",
noTelemetry: "No telemetry yet",
enableTelemetry: "Enable telemetry",
enableSyntheticLinks: "Enable test links",
saveTestFlag: "Save flag",
nodeManagement: "Node management",
nodeScope: "View scope",
currentClusterNodes: "Active cluster nodes",
allNodes: "All platform nodes",
showAllPlatformNodes: "Show all platform nodes",
currentClusterMembership: "Active cluster membership",
clusterMemberships: "Cluster memberships",
notMemberOfActiveCluster: "not a member",
nodeIdentity: "Physical node identity",
activeClusterScope: "Active cluster scope",
activeClusterScopeText:
"One physical node may belong to multiple clusters. Roles and desired services below belong only to the selected active cluster.",
capabilityConfirmed: "capability confirmed by heartbeat",
capabilityMissing: "capability not reported by node",
capabilityUnknown: "capability unconfirmed: no heartbeat",
nodeGlobalInventoryText:
"Each physical node is shown once. Membership and roles remain cluster-scoped, so the same node may have different assignments in different clusters.",
nodeSearch: "Node search",
groupNodesBy: "Group by",
groupByMembership: "membership",
groupByHealth: "health",
groupByOwnership: "ownership",
groupByClusterCount: "cluster count",
nodeGroups: "Node groups",
nodeGroupTree: "Group tree",
nodeGroupFilter: "Group filter",
allNodeGroups: "All groups",
nodeGroupCreatePanel: "Create group",
nodeGroupName: "Group name",
parentNodeGroup: "Parent group",
rootNodeGroup: "Root",
ungroupedNodes: "Ungrouped",
createNodeGroup: "Create group",
createSubgroup: "Create subgroup",
collapseGroup: "Collapse",
expandGroup: "Expand",
assignNodeGroup: "Move to group",
removeFromNodeGroup: "Remove from group",
connectExistingNode: "Connect to active cluster",
connectExistingNodeTitle: "Connect existing node",
connectExistingNodeText:
"This creates or re-enables membership for one concrete physical node in the active cluster. Roles below are assigned only in this cluster.",
connectWithRoles: "Connect with roles",
nodeDetails: "Details",
manageNode: "Configure",
nodeFunctions: "Node functions",
nodeFunctionsText:
"One row controls the whole function: role grants permission in the active cluster, desired service requests runtime start, observed state reports node-agent facts.",
rolePermission: "Permission",
permissionGranted: "granted",
permissionDenied: "not allowed",
organizationScopeForEnable: "Organization scope for new enables, optional",
clusterWideRolePlaceholder: "empty = cluster-wide role",
desiredRuntime: "Desired state",
observedRuntime: "Observed",
enableFunction: "Enable function",
disableFunction: "Disable function",
close: "Close",
nodeBriefList: "Compact node list",
noActiveClusterMembership: "Node is not a member of the active cluster",
nodeBriefListHelp:
"The list is grouped as the active cluster tree. Full details, management, roles, services, and statistics open from the node row.",
nodeSearchPlaceholder: "name, key, cluster, status",
nodeGroupInventoryText:
"Groups are a cluster inventory structure. Moving a node to a group changes only its placement inside the active cluster, not roles or membership.",
nodeGroupCreated: "Node group created.",
noNodesTitle: "No nodes",
noNodesByFilter: "No nodes match the current filter.",
cancel: "Cancel",
alreadyMember: "Already in active cluster",
revokedMembership: "Membership revoked",
addNode: "Add node",
addNodeText:
"Connect an existing physical node to the active cluster from the node list: enable platform-wide view and click “Connect to active cluster”.",
joinTokenTitle: "Create join token",
joinTokenText:
"A join token is a temporary pass for rap-node-agent. It does not create a node directly: the agent submits a request and the platform owner approves it.",
ttlHours: "Lifetime, hours",
ttlHelp: "After this time the token becomes invalid even if unused. For manual enrollment, 124 hours is usually enough.",
maxUses: "Maximum uses",
maxUsesHelp: "How many node-agents may use this token. The safest default is one token for one new node.",
tokenPurpose: "Token purpose",
nodeOwnership: "Node ownership type",
suggestedRoles: "Allowed/expected roles",
generatedScope: "Generated scope",
generatedScopeHelp:
"JSON is generated automatically from the settings above. Operators should not hand-write it and risk syntax or access-scope mistakes.",
manualApprovalRequired: "Manual request approval is required",
nodeRoles: "Node roles",
desiredServices: "Desired services",
observedServices: "Observed services",
noRoles: "No roles yet",
noServices: "No services yet",
manageInCluster: "Manage in cluster",
rolesAndServices: "Roles and services",
links: "Links",
fabricMap: "Fabric traffic map",
fabricIngressLayer: "Ingress",
fabricNodeLayer: "Cluster nodes",
fabricEgressLayer: "Egress pools",
observedPeerLinks: "Observed links",
placementIntent: "control-plane placement",
fabricEntryPoints: "Entry points",
fabricEntryPointHelp: "Logical external ingress points for the cluster. They hide concrete nodes from organizations and clients.",
fabricEgressPools: "Egress pools",
fabricEgressPoolHelp: "Logical exits to external networks, for example “Office Moscow”. Organizations use the pool, not a concrete node.",
endpointName: "Name",
publicEndpoint: "Public endpoint",
endpointType: "Entry type",
description: "Description",
routeScope: "Route scope JSON",
createEntryPoint: "Create entry point",
createEgressPool: "Create egress pool",
endpointNodes: "Assigned nodes",
assignEndpointNode: "Assign node",
selectNode: "Select node",
assignedNodesEmpty: "No nodes assigned yet",
entryPointsEmpty: "No entry points created yet.",
egressPoolsEmpty: "No egress pools created yet.",
addressNotSet: "address not set",
descriptionNotSet: "description not set",
servicePlacement: "Service placement",
trafficFlow: "Node traffic flows",
organizationTestFlag: "Organization testing",
organizationId: "Organization ID",
saveOrganizationFlag: "Save organization flag",
noLinks: "No links yet",
recentHeartbeats: "Recent heartbeats",
memory: "Memory",
cpu: "CPU",
processes: "Processes",
},
} satisfies Record<Language, Record<string, string>>;
function normalizeAuthResult(result: AuthResult): WebAdminSession {
const userId = result.user.id || result.user.ID || "";
const email = result.user.email || result.user.Email || "";
const authSessionId = result.auth_session.id || result.auth_session.ID || "";
return {
userId,
email,
authSessionId,
accessToken: result.tokens.access_token,
refreshToken: result.tokens.refresh_token,
accessTokenExpiresAt: result.tokens.access_token_expires_at,
refreshTokenExpiresAt: result.tokens.refresh_token_expires_at,
};
}
export function App() {
const [baseUrl] = useState(() => {
const stored = localStorage.getItem(storageKeys.baseUrl)?.trim();
return !stored || stored.startsWith(legacyDirectBackendPrefix) ? defaultBaseUrl : stored;
});
const [session, setSession] = useState<WebAdminSession | null>(null);
const [language, setLanguage] = useState<Language>(() => (localStorage.getItem(storageKeys.language) === "en" ? "en" : "ru"));
const [actorUserId, setActorUserId] = useState("");
const [loginForm, setLoginForm] = useState({
email: "",
password: "",
deviceLabel: "Панель владельца платформы",
trustDevice: true,
});
const [installationStatus, setInstallationStatus] = useState<InstallationStatus | null>(null);
const [bootstrapForm, setBootstrapForm] = useState({
email: "",
password: "",
activationPayload: "",
activationSignature: "",
});
const [activeView, setActiveView] = useState<ViewId>("command");
const [selectedClusterId, setSelectedClusterId] = useState("");
const [clusters, setClusters] = useState<Cluster[]>([]);
const [clusterSummaries, setClusterSummaries] = useState<ClusterAdminSummary[]>([]);
const [authority, setAuthority] = useState<ClusterAuthorityState | null>(null);
const [nodes, setNodes] = useState<ClusterNode[]>([]);
const [nodeGroups, setNodeGroups] = useState<ClusterNodeGroup[]>([]);
const [allNodesByCluster, setAllNodesByCluster] = useState<Record<string, ClusterNode[]>>({});
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
const [rolesByNode, setRolesByNode] = useState<Record<string, RoleAssignment[]>>({});
const [desiredWorkloadsByNode, setDesiredWorkloadsByNode] = useState<Record<string, NodeWorkloadDesiredState[]>>({});
const [workloadsByNode, setWorkloadsByNode] = useState<Record<string, WorkloadStatus[]>>({});
const [heartbeatsByNode, setHeartbeatsByNode] = useState<Record<string, NodeHeartbeat[]>>({});
const [telemetryByNode, setTelemetryByNode] = useState<Record<string, NodeTelemetryObservation[]>>({});
const [meshLinks, setMeshLinks] = useState<MeshLink[]>([]);
const [syntheticMeshConfigsByNode, setSyntheticMeshConfigsByNode] = useState<Record<string, NodeSyntheticMeshConfig>>({});
const [qosPolicies, setQosPolicies] = useState<QoSPolicy[]>([]);
const [entryPoints, setEntryPoints] = useState<FabricEntryPoint[]>([]);
const [entryPointNodesById, setEntryPointNodesById] = useState<Record<string, FabricEntryPointNode[]>>({});
const [egressPools, setEgressPools] = useState<FabricEgressPool[]>([]);
const [egressPoolNodesById, setEgressPoolNodesById] = useState<Record<string, FabricEgressPoolNode[]>>({});
const [testingFlags, setTestingFlags] = useState<FabricTestingFlag[]>([]);
const [vpnConnections, setVPNConnections] = useState<VPNConnection[]>([]);
const [vpnLeases, setVPNLeases] = useState<Record<string, VPNConnectionLease | null>>({});
const [audit, setAudit] = useState<AuditEvent[]>([]);
const [organizationId, setOrganizationId] = useState("");
const [organizationSummary, setOrganizationSummary] = useState<OrganizationAdminSummary | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [clusterForm, setClusterForm] = useState({ slug: "", name: "", region: "" });
const [clusterSettingsForm, setClusterSettingsForm] = useState({ name: "", status: "active", region: "", metadataJson: "{}" });
const [nodeGroupForm, setNodeGroupForm] = useState({ name: "", parentGroupId: "" });
const [entryPointForm, setEntryPointForm] = useState({ name: "", endpointType: "client_access", publicEndpoint: "" });
const [egressPoolForm, setEgressPoolForm] = useState({ name: "", description: "", routeScope: "{\n \"routes\": []\n}" });
const [joinTokenForm, setJoinTokenForm] = useState({
ttlHours: 24,
maxUses: 1,
roles: ["entry-node"],
ownershipType: "platform_managed",
purpose: "",
});
const [lastJoinToken, setLastJoinToken] = useState<CreatedJoinToken | null>(null);
const [authorityForm, setAuthorityForm] = useState({ authorityState: "authoritative", mutationMode: "normal", notes: "" });
const [roleOrgScope, setRoleOrgScope] = useState("");
const [nodeViewScope, setNodeViewScope] = useState<"cluster" | "all">("cluster");
const [allNodeSearch, setAllNodeSearch] = useState("");
const [nodeGroupFilterId, setNodeGroupFilterId] = useState("");
const [collapsedNodeGroupKeys, setCollapsedNodeGroupKeys] = useState<string[]>([]);
const [allNodeGroupBy, setAllNodeGroupBy] = useState<"membership" | "health" | "ownership" | "cluster_count">("membership");
const [attachNodeDialog, setAttachNodeDialog] = useState<{ node: ClusterNode; memberships: Array<{ cluster: Cluster; node: ClusterNode }> } | null>(null);
const [attachNodeRoles, setAttachNodeRoles] = useState<string[]>([]);
const [nodeInfoDialog, setNodeInfoDialog] = useState<NodeInventoryEntry | null>(null);
const [nodeInfoMode, setNodeInfoMode] = useState<"details" | "manage">("details");
const [nodeRoleDrafts, setNodeRoleDrafts] = useState<Record<string, string>>({});
const [nodeWorkloadDrafts, setNodeWorkloadDrafts] = useState<Record<string, string>>({});
const [entryPointNodeDrafts, setEntryPointNodeDrafts] = useState<Record<string, string>>({});
const [egressPoolNodeDrafts, setEgressPoolNodeDrafts] = useState<Record<string, string>>({});
const [nodeTestingDrafts, setNodeTestingDrafts] = useState<Record<string, { telemetry: boolean; links: boolean }>>({});
const [testingOrgId, setTestingOrgId] = useState("");
const [testingOrgDraft, setTestingOrgDraft] = useState({ telemetry: true, links: true });
const [workloadForm, setWorkloadForm] = useState({
nodeId: "",
serviceType: "entry-node",
desiredState: "enabled",
runtimeMode: "container",
version: "",
configJson: "{}",
environmentJson: "{}",
});
const [vpnForm, setVPNForm] = useState({
organizationId: "",
name: "",
protocolFamily: "generic",
desiredState: "disabled",
credentialRef: "",
targetEndpointJson: "{}",
allowedNodePolicyJson: `{"mode":"explicit","node_ids":[]}`,
routingUsageJson: "[]",
routePolicyJson: "{}",
qosPolicyJson: "{}",
placementPolicyJson: "{}",
});
const client = useMemo(() => new AdminApiClient({ baseUrl, actorUserId }), [baseUrl, actorUserId]);
const authClient = useMemo(() => new AdminApiClient({ baseUrl, actorUserId: "" }), [baseUrl]);
const clusterScopeRequestSeq = useRef(0);
const t = copy[language];
const selectedCluster = clusters.find((cluster) => cluster.id === selectedClusterId) || null;
const selectedSummary = clusterSummaries.find((summary) => summary.cluster_id === selectedClusterId) || null;
const joinTokenScope = useMemo(() => buildJoinTokenScope(joinTokenForm), [joinTokenForm]);
const allNodeInventory = useMemo(() => {
const grouped = new Map<
string,
{
node: ClusterNode;
memberships: Array<{ cluster: Cluster; node: ClusterNode }>;
}
>();
for (const cluster of clusters) {
for (const node of allNodesByCluster[cluster.id] || []) {
const current = grouped.get(node.id);
if (current) {
current.memberships.push({ cluster, node });
if ((node.last_seen_at || "") > (current.node.last_seen_at || "")) {
current.node = node;
}
} else {
grouped.set(node.id, { node, memberships: [{ cluster, node }] });
}
}
}
return Array.from(grouped.values()).sort((left, right) => left.node.name.localeCompare(right.node.name));
}, [allNodesByCluster, clusters]);
const groupedAllNodeInventory = useMemo(
() => groupNodeInventory(allNodeInventory, selectedClusterId, allNodeSearch, allNodeGroupBy, language),
[allNodeInventory, allNodeGroupBy, allNodeSearch, language, selectedClusterId],
);
const visibleNodeInventory = useMemo(() => {
const normalizedSearch = allNodeSearch.trim().toLowerCase();
const allowedGroupIds = nodeGroupFilterId ? new Set([nodeGroupFilterId, ...descendantGroupIds(nodeGroupFilterId, nodeGroups)]) : null;
return allNodeInventory.filter((entry) => {
const hasActiveClusterMembership = entry.memberships.some((membership) => membership.cluster.id === selectedClusterId);
if (nodeViewScope !== "all" && !hasActiveClusterMembership) {
return false;
}
if (allowedGroupIds) {
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
if (!activeMembership?.node.node_group_id || !allowedGroupIds.has(activeMembership.node.node_group_id)) {
return false;
}
}
return !normalizedSearch || nodeInventoryMatches(entry, normalizedSearch);
});
}, [allNodeInventory, allNodeSearch, nodeGroupFilterId, nodeGroups, nodeViewScope, selectedClusterId]);
const visibleNodeTreeRows = useMemo(
() => buildNodeInventoryTreeRows(visibleNodeInventory, nodeGroups, selectedClusterId, t, new Set(collapsedNodeGroupKeys)),
[collapsedNodeGroupKeys, nodeGroups, selectedClusterId, t, visibleNodeInventory],
);
useEffect(() => {
let cancelled = false;
authClient
.getInstallationStatus()
.then((status) => {
if (!cancelled) {
setInstallationStatus(status);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Не удалось загрузить статус установки.");
}
});
return () => {
cancelled = true;
};
}, [authClient]);
useEffect(() => {
if (!selectedCluster) {
setClusterSettingsForm({ name: "", status: "active", region: "", metadataJson: "{}" });
return;
}
setClusterSettingsForm({
name: selectedCluster.name,
status: selectedCluster.status || "active",
region: selectedCluster.region || "",
metadataJson: JSON.stringify(selectedCluster.metadata || {}, null, 2),
});
}, [selectedCluster]);
useEffect(() => {
setNodeGroupFilterId("");
setNodeGroupForm({ name: "", parentGroupId: "" });
setCollapsedNodeGroupKeys([]);
}, [selectedClusterId]);
useEffect(() => {
setAttachNodeDialog(null);
setAttachNodeRoles([]);
}, [selectedClusterId]);
useEffect(() => {
localStorage.setItem(storageKeys.baseUrl, baseUrl);
localStorage.setItem(storageKeys.language, language);
if (session) {
localStorage.setItem(`${storageKeys.language}.${session.userId}`, language);
} else {
localStorage.removeItem(storageKeys.auth);
localStorage.removeItem(storageKeys.actorUserId);
}
}, [baseUrl, actorUserId, language, session]);
useEffect(() => {
if (!session) {
return;
}
const userLanguage = localStorage.getItem(`${storageKeys.language}.${session.userId}`);
if (userLanguage === "ru" || userLanguage === "en") {
setLanguage(userLanguage);
}
}, [session?.userId]);
useEffect(() => {
if (session) {
void refreshAll();
}
// First load only. The operator keeps refresh timing explicit after that.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.userId]);
async function refreshAll(preferredClusterId = selectedClusterId) {
if (!actorUserId.trim()) {
setError(t.noLoginError);
return;
}
setLoading(true);
setError("");
setNotice("");
try {
const [loadedClusters, loadedSummaries] = await Promise.all([client.listClusters(), client.listClusterSummaries()]);
setClusters(loadedClusters);
setClusterSummaries(loadedSummaries);
const allNodeEntries = await Promise.all(loadedClusters.map(async (cluster) => [cluster.id, await client.listNodes(cluster.id)] as const));
setAllNodesByCluster(Object.fromEntries(allNodeEntries));
const clusterId = preferredClusterId || loadedClusters[0]?.id || "";
setSelectedClusterId(clusterId);
if (clusterId) {
await loadClusterScope(clusterId);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Неизвестная ошибка панели управления платформой.");
} finally {
setLoading(false);
}
}
async function loadClusterScope(clusterId: string) {
const requestSeq = ++clusterScopeRequestSeq.current;
const [
loadedNodes,
loadedNodeGroups,
loadedJoinRequests,
loadedAuthority,
loadedAudit,
loadedMeshLinks,
loadedQosPolicies,
loadedEntryPoints,
loadedEgressPools,
loadedVPNConnections,
loadedTestingFlags,
] =
await Promise.all([
client.listNodes(clusterId),
client.listNodeGroups(clusterId),
client.listJoinRequests(clusterId),
client.getClusterAuthority(clusterId),
client.listAudit(clusterId),
client.listMeshLinks(clusterId),
client.listQoSPolicies(clusterId),
client.listFabricEntryPoints(clusterId),
client.listFabricEgressPools(clusterId),
client.listVPNConnections(clusterId),
client.listFabricTestingFlags(),
]);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setNodes(loadedNodes);
setNodeGroups(loadedNodeGroups);
setJoinRequests(loadedJoinRequests);
setAuthority(loadedAuthority);
setAuthorityForm({
authorityState: loadedAuthority.authority_state,
mutationMode: loadedAuthority.mutation_mode,
notes: loadedAuthority.notes || "",
});
setAudit(loadedAudit);
setMeshLinks(loadedMeshLinks);
setQosPolicies(loadedQosPolicies);
setEntryPoints(loadedEntryPoints);
setEgressPools(loadedEgressPools);
setVPNConnections(loadedVPNConnections);
setTestingFlags(loadedTestingFlags);
const [entryPointNodeEntries, egressPoolNodeEntries] = await Promise.all([
Promise.all(loadedEntryPoints.map(async (entryPoint) => [entryPoint.id, await client.listFabricEntryPointNodes(clusterId, entryPoint.id)] as const)),
Promise.all(loadedEgressPools.map(async (pool) => [pool.id, await client.listFabricEgressPoolNodes(clusterId, pool.id)] as const)),
]);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setEntryPointNodesById(Object.fromEntries(entryPointNodeEntries));
setEgressPoolNodesById(Object.fromEntries(egressPoolNodeEntries));
const roleEntries = await Promise.all(loadedNodes.map(async (node) => [node.id, await client.listNodeRoles(clusterId, node.id)] as const));
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setRolesByNode(Object.fromEntries(roleEntries));
const desiredEntries = await Promise.all(loadedNodes.map(async (node) => [node.id, await client.listDesiredWorkloads(clusterId, node.id)] as const));
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setDesiredWorkloadsByNode(Object.fromEntries(desiredEntries));
const workloadEntries = await Promise.all(
loadedNodes.map(async (node) => [node.id, await client.listWorkloadStatuses(clusterId, node.id)] as const),
);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setWorkloadsByNode(Object.fromEntries(workloadEntries));
const heartbeatEntries = await Promise.all(
loadedNodes.map(async (node) => [node.id, await client.listNodeHeartbeats(clusterId, node.id, 60)] as const),
);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setHeartbeatsByNode(Object.fromEntries(heartbeatEntries));
const telemetryEntries = await Promise.all(
loadedNodes.map(async (node) => [node.id, await client.listNodeTelemetry(clusterId, node.id, 120)] as const),
);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setTelemetryByNode(Object.fromEntries(telemetryEntries));
const syntheticMeshConfigEntries = await Promise.all(
loadedNodes.map(async (node) => [node.id, await client.getNodeSyntheticMeshConfig(clusterId, node.id)] as const),
);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setSyntheticMeshConfigsByNode(Object.fromEntries(syntheticMeshConfigEntries));
const leaseEntries = await Promise.all(
loadedVPNConnections.map(async (connection) => [connection.id, await client.getActiveVPNLease(clusterId, connection.id)] as const),
);
if (requestSeq !== clusterScopeRequestSeq.current) {
return;
}
setVPNLeases(Object.fromEntries(leaseEntries));
}
function clearClusterScope() {
setNodes([]);
setNodeGroups([]);
setJoinRequests([]);
setAuthority(null);
setRolesByNode({});
setDesiredWorkloadsByNode({});
setWorkloadsByNode({});
setHeartbeatsByNode({});
setTelemetryByNode({});
setMeshLinks([]);
setSyntheticMeshConfigsByNode({});
setQosPolicies([]);
setEntryPoints([]);
setEntryPointNodesById({});
setEgressPools([]);
setEgressPoolNodesById({});
setTestingFlags([]);
setVPNConnections([]);
setVPNLeases({});
setAudit([]);
}
async function runAction(action: () => Promise<unknown>, success: string) {
setLoading(true);
setError("");
setNotice("");
try {
await action();
setNotice(success);
await refreshAll();
} catch (err) {
setError(err instanceof Error ? err.message : "Действие не выполнено.");
} finally {
setLoading(false);
}
}
async function handleLogin() {
setLoading(true);
setError("");
setNotice("");
try {
const result = await authClient.login({
email: loginForm.email,
password: loginForm.password,
deviceLabel: loginForm.deviceLabel,
trustDevice: loginForm.trustDevice,
});
const nextSession = normalizeAuthResult(result);
if (!nextSession.userId || !nextSession.authSessionId) {
throw new Error("Ответ входа не содержит пользователя или сессию.");
}
const accessClient = new AdminApiClient({ baseUrl, actorUserId: nextSession.userId });
try {
await accessClient.listClusterSummaries();
} catch {
try {
await authClient.revokeAuthSession({
userId: nextSession.userId,
authSessionId: nextSession.authSessionId,
reason: "platform_owner_console_access_denied",
});
} catch {
// Authentication succeeded, but this panel is not authorized for the user.
}
throw new Error(t.accessDenied);
}
setSession(nextSession);
setActorUserId(nextSession.userId);
setNotice(`${t.signedInAs}: ${nextSession.email}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Вход не выполнен.");
} finally {
setLoading(false);
}
}
async function handleBootstrapOwner() {
setLoading(true);
setError("");
setNotice("");
try {
let activationPayload: unknown;
if (installationStatus?.strict_authority) {
if (!bootstrapForm.activationPayload.trim() || !bootstrapForm.activationSignature.trim()) {
throw new Error(t.bootstrapText);
}
activationPayload = JSON.parse(bootstrapForm.activationPayload);
}
const result = await authClient.bootstrapOwner({
email: bootstrapForm.email,
password: bootstrapForm.password,
activationPayload,
activationSignature: bootstrapForm.activationSignature,
});
setInstallationStatus(result.installation);
setLoginForm({ ...loginForm, email: bootstrapForm.email, password: bootstrapForm.password });
setNotice(t.ownerCreated);
} catch (err) {
setError(err instanceof Error ? err.message : "Создание владельца не выполнено.");
} finally {
setLoading(false);
}
}
async function handleLogout() {
const previous = session;
setSession(null);
setActorUserId("");
setClusters([]);
setClusterSummaries([]);
clearClusterScope();
setAllNodesByCluster({});
setSelectedClusterId("");
if (previous?.userId && previous.authSessionId) {
try {
await authClient.revokeAuthSession({
userId: previous.userId,
authSessionId: previous.authSessionId,
reason: "platform_owner_console_logout",
});
} catch {
// The local session is already cleared. Backend revoke failure should not trap the operator in the UI.
}
}
}
async function switchCluster(clusterId: string) {
setSelectedClusterId(clusterId);
clearClusterScope();
setLoading(true);
setError("");
setNotice("");
try {
await loadClusterScope(clusterId);
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось загрузить кластер.");
} finally {
setLoading(false);
}
}
const pendingJoinCount = joinRequests.filter((request) => request.status === "pending").length;
const healthyNodeCount = nodes.filter((node) => node.health_status === "healthy").length;
const riskyNodeCount = nodes.filter((node) => node.health_status === "critical" || node.membership_status !== "active").length;
const activeRoleCount = Object.values(rolesByNode).flat().filter((role) => role.status === "active").length;
const platformTestingFlag = testingFlags.find((flag) => flag.scope_type === "platform" && !flag.scope_id) || null;
const organizationTestingFlag =
testingFlags.find((flag) => flag.scope_type === "organization" && flag.scope_id === testingOrgId && (!flag.cluster_id || flag.cluster_id === selectedClusterId)) ||
null;
const syntheticMeshConfigs = Object.values(syntheticMeshConfigsByNode);
const syntheticConfigEnabledCount = syntheticMeshConfigs.filter((config) => config.enabled).length;
const syntheticRouteCount = syntheticMeshConfigs.reduce((sum, config) => sum + config.routes.length, 0);
const syntheticPeerEndpointCount = syntheticMeshConfigs.reduce((sum, config) => sum + Object.keys(config.peer_endpoints || {}).length, 0);
const syntheticCandidateCount = syntheticMeshConfigs.reduce((sum, config) => sum + countPeerEndpointCandidates(config), 0);
const syntheticPeerDirectoryCount = syntheticMeshConfigs.reduce((sum, config) => sum + (config.peer_directory?.length ?? 0), 0);
const syntheticRecoverySeedCount = syntheticMeshConfigs.reduce((sum, config) => sum + (config.recovery_seeds?.length ?? 0), 0);
const productionForwardingConfigCount = syntheticMeshConfigs.filter((config) => config.production_forwarding).length;
const bootstrapRequired = installationStatus?.bootstrapped === false;
const bootstrapBlocked = bootstrapRequired && !installationStatus?.strict_authority && !installationStatus?.insecure_bootstrap_allowed;
if (!session) {
return (
<main className="loginShell">
{installationStatus && (
<section className="loginCard">
<h1>{installationStatus.bootstrapped ? t.installationLocked : t.bootstrapTitle}</h1>
<StateLine label="Authority" value={`${installationStatus.authority_mode}/${installationStatus.authority_state}`} />
<StateLine label="Strict" value={installationStatus.strict_authority ? "enabled" : "legacy"} />
{installationStatus.root_fingerprint && <StateLine label="Root key" value={shortId(installationStatus.root_fingerprint)} />}
</section>
)}
{bootstrapRequired ? (
<section className="loginCard">
<h1>{t.bootstrapTitle}</h1>
<p className="loginHint">{bootstrapBlocked ? t.insecureBootstrapDisabled : t.bootstrapText}</p>
<label>
{t.email}
<input
value={bootstrapForm.email}
onChange={(event) => setBootstrapForm({ ...bootstrapForm, email: event.target.value })}
autoComplete="username"
/>
</label>
<label>
{t.password}
<input
value={bootstrapForm.password}
onChange={(event) => setBootstrapForm({ ...bootstrapForm, password: event.target.value })}
type="password"
autoComplete="new-password"
/>
</label>
{installationStatus?.strict_authority && (
<>
<label>
{t.activationPayload}
<textarea
value={bootstrapForm.activationPayload}
onChange={(event) => setBootstrapForm({ ...bootstrapForm, activationPayload: event.target.value })}
spellCheck={false}
/>
</label>
<label>
{t.activationSignature}
<input
value={bootstrapForm.activationSignature}
onChange={(event) => setBootstrapForm({ ...bootstrapForm, activationSignature: event.target.value })}
spellCheck={false}
/>
</label>
</>
)}
{error && <div className="errorPanel">{error}</div>}
{notice && <div className="noticePanel">{notice}</div>}
<button
className="primary wide"
onClick={() => void handleBootstrapOwner()}
disabled={
loading ||
bootstrapBlocked ||
!bootstrapForm.email ||
bootstrapForm.password.length < 12 ||
(installationStatus?.strict_authority && (!bootstrapForm.activationPayload || !bootstrapForm.activationSignature))
}
>
{loading ? t.creatingOwner : t.createOwner}
</button>
</section>
) : (
<section className="loginCard">
<h1>{t.signInTitle}</h1>
<label>
{t.email}
<input
value={loginForm.email}
onChange={(event) => setLoginForm({ ...loginForm, email: event.target.value })}
autoComplete="username"
/>
</label>
<label>
{t.password}
<input
value={loginForm.password}
onChange={(event) => setLoginForm({ ...loginForm, password: event.target.value })}
type="password"
autoComplete="current-password"
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleLogin();
}
}}
/>
</label>
{error && <div className="errorPanel">{error}</div>}
{notice && <div className="noticePanel">{notice}</div>}
<button className="primary wide" onClick={() => void handleLogin()} disabled={loading || !loginForm.email || !loginForm.password}>
{loading ? t.signingIn : t.signIn}
</button>
</section>
)}
</main>
);
}
return (
<main className="consoleShell">
<aside className="sideRail">
<div className="brandMark">SAF</div>
<p className="sideKicker">{t.productOwner}</p>
<h1>{t.controlPlane}</h1>
<p className="sideText">{t.sideText}</p>
<nav className="railNav">
{views.filter((view) => view.id !== "enrollment" && view.id !== "roles").map((view) => (
<button key={view.id} className={activeView === view.id ? "active" : ""} onClick={() => setActiveView(view.id)}>
{view[language]}
</button>
))}
</nav>
</aside>
<section className="workspace">
<header className="topBar">
<div>
<p className="eyebrow">Secure Access Fabric</p>
<h2>{selectedCluster ? selectedCluster.name : t.consoleTitle}</h2>
<p className="muted">{t.boundary}</p>
</div>
<div className="clusterPicker">
<label>
{t.activeCluster}
<select value={selectedClusterId} onChange={(event) => void switchCluster(event.target.value)}>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name}
</option>
))}
</select>
</label>
<span>
{t.slugLabel}: {selectedCluster?.slug || "н/д"}
</span>
</div>
<button className="primary" onClick={() => void refreshAll()} disabled={loading}>
{loading ? t.refreshing : t.refresh}
</button>
<div className="profilePanel">
<strong>{t.profile}</strong>
<span>{session.email}</span>
<label>
{t.language}
<select value={language} onChange={(event) => setLanguage(event.target.value as Language)}>
<option value="ru">Русский</option>
<option value="en">{language === "ru" ? "Английский" : "English"}</option>
</select>
</label>
<button className="ghost" onClick={() => void handleLogout()} disabled={loading}>
{t.logout}
</button>
</div>
</header>
{error && <div className="errorPanel">{error}</div>}
{notice && <div className="noticePanel">{notice}</div>}
{selectedCluster && nodes.length === 0 && (
<div className="noticePanel">
<strong>{t.emptyLiveTitle}.</strong> {t.emptyLiveText}
</div>
)}
{activeView === "command" && (
<section className="grid five">
<Metric label="Кластеры" value={clusters.length} tone="steel" />
<Metric label="Узлы в области" value={nodes.length} tone="green" />
<Metric label="Здоровые узлы" value={healthyNodeCount} tone="green" />
<Metric label="Ожидают подключения" value={pendingJoinCount} tone="amber" />
<Metric label="Рискованные состояния" value={riskyNodeCount} tone="red" />
<article className="card span3">
<h3>Общее состояние кластеров</h3>
<DataTable
columns={["кластер", "authority", "ключ", "режим изменений", "узлы", "заявки", "роли", "последний сигнал"]}
rows={clusterSummaries.map((summary) => [
summary.name,
summary.authority_state,
shortId(summary.cluster_key_fingerprint),
summary.mutation_mode,
`${summary.healthy_node_count}/${summary.node_count}`,
String(summary.pending_join_count),
String(summary.active_role_assignment_count),
formatDate(summary.last_node_seen_at),
])}
/>
</article>
<article className="card span2">
<h3>Authority выбранного кластера</h3>
{authority ? (
<div className="stateList">
<StateLine label="Authority" value={authority.authority_state} />
<StateLine label="Режим изменений" value={authority.mutation_mode} />
<StateLine label="Терм" value={String(authority.term)} />
<StateLine label="Cluster key" value={shortId(selectedSummary?.cluster_key_fingerprint)} />
<StateLine label="Обновлено" value={formatDate(authority.updated_at)} />
</div>
) : (
<EmptyState title="Нет состояния authority" text="Выберите кластер, чтобы загрузить состояние authority." />
)}
</article>
<article className="card span2">
<h3>Граница платформы</h3>
<p className="muted">
Эта панель предназначена для владельца продукта / владельца платформы. Панели организаций должны использовать безопасные
проекции и не раскрывать mesh internals, peer cache, route cache, секреты или данные других tenants.
</p>
</article>
<article className="card span3">
<h3>Текущие сигналы кластера</h3>
<div className="signalStrip">
<Signal label="Активные роли" value={String(activeRoleCount)} />
<Signal label="Отчеты сервисов" value={String(Object.values(workloadsByNode).filter((items) => items.length > 0).length)} />
<Signal label="Наблюдения связей" value={String(meshLinks.length)} />
<Signal label="Synthetic configs" value={`${syntheticConfigEnabledCount}/${nodes.length}`} />
</div>
</article>
</section>
)}
{activeView === "clusters" && (
<section className="grid two">
<article className="card span2">
<div className="cardHead">
<div>
<h3>{t.clusterCatalog}</h3>
<p className="muted">{t.clusterCatalogText}</p>
</div>
<span className="pill">{clusterCountLabel(clusters.length, language)}</span>
</div>
<div className="clusterCatalog">
{clusters.map((cluster) => {
const summary = clusterSummaries.find((item) => item.cluster_id === cluster.id);
const isSelected = cluster.id === selectedClusterId;
return (
<article key={cluster.id} className={`clusterCard ${isSelected ? "selected" : ""}`}>
<div className="clusterCardMain">
<div>
<p className="eyebrow">{cluster.region || "регион не задан"}</p>
<h4>{cluster.name}</h4>
<p className="muted">
{t.slugLabel}: <strong>{cluster.slug}</strong>
</p>
</div>
<div className="clusterCardActions">
<Status value={cluster.status} />
{isSelected ? (
<span className="pill good">{t.selected}</span>
) : (
<button onClick={() => void switchCluster(cluster.id)}>{t.makeActive}</button>
)}
<button
className="ghost"
onClick={() => {
void switchCluster(cluster.id);
setActiveView("cluster-settings");
}}
>
{t.openSettings}
</button>
</div>
</div>
<div className="signalStrip compact">
<Signal label="Узлы" value={summary ? `${summary.healthy_node_count}/${summary.node_count}` : "н/д"} />
<Signal label="Заявки" value={String(summary?.pending_join_count ?? "н/д")} />
<Signal label="Роли" value={String(summary?.active_role_assignment_count ?? "н/д")} />
<Signal label="Последний сигнал" value={formatDate(summary?.last_node_seen_at)} />
</div>
<details>
<summary>{t.clusterDetails}</summary>
<div className="stateList">
<StateLine label="ID" value={cluster.id} />
<StateLine label={t.slugLabel} value={cluster.slug} />
<StateLine label="Статус" value={statusLabel(cluster.status)} />
<StateLine label="Authority" value={summary ? `${summary.authority_state}/${summary.mutation_mode}` : "неизвестно"} />
<StateLine label="Создан" value={formatDate(cluster.created_at)} />
<StateLine label="Обновлен" value={formatDate(cluster.updated_at || cluster.created_at)} />
</div>
</details>
</article>
);
})}
{clusters.length === 0 && <EmptyState title="Кластеров нет" text="Создайте первый кластер, затем подключите стартовый node-agent." />}
</div>
</article>
<article className="card">
<h3>{t.createCluster}</h3>
<FormGrid>
<label>
{t.slugLabel}
<input value={clusterForm.slug} onChange={(event) => setClusterForm({ ...clusterForm, slug: event.target.value })} />
</label>
<label>
Название
<input value={clusterForm.name} onChange={(event) => setClusterForm({ ...clusterForm, name: event.target.value })} />
</label>
<label>
Регион
<input value={clusterForm.region} onChange={(event) => setClusterForm({ ...clusterForm, region: event.target.value })} />
</label>
</FormGrid>
<p className="muted">{t.slugHelp}</p>
<button
className="primary"
disabled={!clusterForm.slug || !clusterForm.name}
onClick={() =>
void runAction(async () => {
await client.createCluster({ slug: clusterForm.slug, name: clusterForm.name, region: clusterForm.region || null });
setClusterForm({ slug: "", name: "", region: "" });
}, "Кластер создан.")
}
>
{t.createCluster}
</button>
</article>
<article className="card">
<h3>Что такое технический код?</h3>
<p className="muted">{t.slugHelp}</p>
<p className="muted">
Для человека основное поле название. Для системы и операторов технический код. Он нужен, чтобы сценарии, логи и будущие
endpoint-адреса не зависели от переименования кластера.
</p>
</article>
</section>
)}
{activeView === "cluster-settings" && (
<section className="grid two">
{!selectedCluster && <EmptyState title="Кластер не выбран" text="Выберите активный кластер, чтобы открыть настройки." />}
{selectedCluster && (
<>
<article className="card">
<h3>Identity кластера</h3>
<p className="muted">Базовые параметры хранятся в PostgreSQL. Slug остается неизменяемым идентификатором для операторов и скриптов.</p>
<FormGrid>
<label>
ID
<input value={selectedCluster.id} readOnly />
</label>
<label>
Slug
<input value={selectedCluster.slug} readOnly />
</label>
<label>
Название
<input
value={clusterSettingsForm.name}
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, name: event.target.value })}
/>
</label>
<label>
Статус
<select
value={clusterSettingsForm.status}
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, status: event.target.value })}
>
<option value="active">active, работает</option>
<option value="disabled">disabled, отключен</option>
</select>
</label>
<label>
Регион
<input
value={clusterSettingsForm.region}
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, region: event.target.value })}
placeholder="например ru-msk-1"
/>
</label>
<label>
Обновлен
<input value={formatDate(selectedCluster.updated_at || selectedCluster.created_at)} readOnly />
</label>
</FormGrid>
<label className="wideLabel">
Metadata JSON
<textarea
value={clusterSettingsForm.metadataJson}
onChange={(event) => setClusterSettingsForm({ ...clusterSettingsForm, metadataJson: event.target.value })}
rows={8}
spellCheck={false}
/>
</label>
<button
className="primary"
disabled={!clusterSettingsForm.name.trim()}
onClick={() =>
confirmHighRisk("Сохранить базовые настройки кластера") &&
void runAction(async () => {
const metadata = parseJSONObject(clusterSettingsForm.metadataJson || "{}", "Metadata JSON");
await client.updateCluster(selectedCluster.id, {
name: clusterSettingsForm.name,
status: clusterSettingsForm.status,
region: clusterSettingsForm.region || null,
metadata,
});
}, "Настройки кластера сохранены.")
}
>
Сохранить настройки кластера
</button>
</article>
<article className="card">
<h3>Authority и режим изменений</h3>
<p className="muted">Эта секция защищает кластер от split-brain: minority/read-only сегменты не должны принимать изменения политик.</p>
<div className="stateGrid">
<StateLine label="Authority" value={authority?.authority_state || "неизвестно"} />
<StateLine label="Mutation mode" value={authority?.mutation_mode || "неизвестно"} />
<StateLine label="Term" value={String(authority?.term ?? "н/д")} />
<StateLine label="Cluster key" value={shortId(selectedSummary?.cluster_key_fingerprint)} />
<StateLine label="Последнее изменение" value={formatDate(authority?.updated_at)} />
</div>
<FormGrid>
<label>
Состояние authority
<select
value={authorityForm.authorityState}
onChange={(event) => setAuthorityForm({ ...authorityForm, authorityState: event.target.value })}
>
<option value="authoritative">authoritative, основной</option>
<option value="minority">minority, меньшинство</option>
<option value="isolated">isolated, изолирован</option>
<option value="recovery">recovery, восстановление</option>
</select>
</label>
<label>
Режим изменений
<select value={authorityForm.mutationMode} onChange={(event) => setAuthorityForm({ ...authorityForm, mutationMode: event.target.value })}>
<option value="normal">normal, обычный</option>
<option value="read_only">read_only, только чтение</option>
<option value="recovery_override">recovery_override, восстановление</option>
</select>
</label>
<label>
Примечание
<input value={authorityForm.notes} onChange={(event) => setAuthorityForm({ ...authorityForm, notes: event.target.value })} />
</label>
</FormGrid>
<button
disabled={!selectedClusterId}
onClick={() =>
confirmHighRisk("Изменить authority state кластера") &&
void runAction(
() =>
client.updateClusterAuthority(selectedClusterId, {
authorityState: authorityForm.authorityState,
mutationMode: authorityForm.mutationMode,
notes: authorityForm.notes,
}),
"Authority кластера обновлен.",
)
}
>
Обновить authority
</button>
</article>
<article className="card">
<h3>Safety / quorum</h3>
<div className="stateGrid">
<StateLine label="Узлы" value={String(selectedSummary?.node_count ?? nodes.length)} />
<StateLine label="Healthy" value={String(selectedSummary?.healthy_node_count ?? healthyNodeCount)} />
<StateLine label="Pending join" value={String(selectedSummary?.pending_join_count ?? joinRequests.filter((item) => item.status === "pending").length)} />
<StateLine label="Последний узел" value={formatDate(selectedSummary?.last_node_seen_at)} />
</div>
<p className="muted">
Минимальный размер, quorum policy и split-brain rules пока не имеют отдельного runtime-переключателя. Сейчас защита выполняется через
authority/mutation mode, explicit node approval и аудит.
</p>
</article>
<article className="card">
<h3>Telemetry / testing</h3>
<div className="stateGrid">
<StateLine label="Telemetry flag" value={platformTestingFlag?.telemetry_enabled ? "включен" : "выключен"} />
<StateLine label="Synthetic links" value={platformTestingFlag?.synthetic_links_enabled ? "включены" : "выключены"} />
<StateLine label="Хранение истории, часов" value={String(platformTestingFlag?.history_retention_hours ?? "н/д")} />
</div>
<p className="muted">
Это тестовый контур наблюдаемости: heartbeat/telemetry реальные, а связи Fabric сейчас synthetic. Production mesh traffic здесь пока не
отображается.
</p>
</article>
<article className="card">
<h3>Storage / updates</h3>
<div className="stateGrid">
<StateLine label="Version Storage" value="архитектура зафиксирована, runtime не реализован" />
<StateLine label="Update cache" value={`${nodesWithRole("update-cache", rolesByNode).length} узл.`} />
<StateLine label="File/config cache" value={`${nodesWithRole("file-storage-cache", rolesByNode).length} узл.`} />
</div>
<p className="muted">
Version Storage будет хранить stable/current/candidate и signed artifacts. Сейчас это не production updater runtime.
</p>
</article>
<article className="card">
<h3>Admin endpoints</h3>
<div className="stateGrid">
<StateLine label="Entry nodes" value={`${nodesWithRole("entry-node", rolesByNode).length} узл.`} />
<StateLine label="Relay nodes" value={`${nodesWithRole("relay-node", rolesByNode).length} узл.`} />
<StateLine label="Core mesh" value={`${nodesWithRole("core-mesh", rolesByNode).length} узл.`} />
</div>
<p className="muted">
Панель кластера не переезжает автоматически на storage-узел. Cluster Admin Endpoint должен быть назначен отдельной explicit ролью на
ingress/admin-capable узле.
</p>
</article>
</>
)}
</section>
)}
{activeView === "nodes" && (
<section className="grid two">
<article className="card span2">
<div className="cardHead">
<div>
<h3>{t.nodeManagement}</h3>
<p className="muted">
Единый краткий список узлов. По умолчанию показан активный кластер; включите общий режим, чтобы увидеть весь инвентарь платформы.
</p>
</div>
<div className="actions">
<label className="checkLine">
<input
type="checkbox"
checked={nodeViewScope === "all"}
onChange={(event) => setNodeViewScope(event.target.checked ? "all" : "cluster")}
/>
{t.showAllPlatformNodes}
</label>
<button
className="ghost"
onClick={() => {
setNodeViewScope("all");
setAllNodeSearch("");
}}
>
{t.showAllPlatformNodes}
</button>
</div>
</div>
<div className="signalStrip compact">
<Signal label="Узлы активного кластера" value={String(nodes.length)} />
<Signal label="Все узлы" value={String(allNodeInventory.length)} />
<Signal label="Заявки" value={String(pendingJoinCount)} />
<Signal label="Активные роли" value={String(activeRoleCount)} />
</div>
<p className="muted">{t.addNodeText}</p>
</article>
<article className="card span2">
<div className="cardHead">
<div>
<h3>{t.nodeBriefList}</h3>
<p className="muted">{t.nodeBriefListHelp}</p>
</div>
<span className="pill">{visibleNodeInventory.length}</span>
</div>
<FormGrid>
<label>
{t.nodeSearch}
<input value={allNodeSearch} onChange={(event) => setAllNodeSearch(event.target.value)} placeholder={t.nodeSearchPlaceholder} />
</label>
<label>
{t.nodeGroupFilter}
<select value={nodeGroupFilterId} onChange={(event) => setNodeGroupFilterId(event.target.value)}>
<option value="">{t.allNodeGroups}</option>
{nodeGroups.map((group) => (
<option key={group.id} value={group.id}>
{groupPathLabel(group, nodeGroups)}
</option>
))}
</select>
</label>
</FormGrid>
<p className="muted">{t.nodeGroupInventoryText}</p>
<h4>{t.nodeGroupCreatePanel}</h4>
<FormGrid>
<label>
{t.nodeGroupName}
<input value={nodeGroupForm.name} onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, name: event.target.value })} />
</label>
<label>
{t.parentNodeGroup}
<select value={nodeGroupForm.parentGroupId} onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, parentGroupId: event.target.value })}>
<option value="">{t.rootNodeGroup}</option>
{nodeGroups.map((group) => (
<option key={group.id} value={group.id}>
{groupPathLabel(group, nodeGroups)}
</option>
))}
</select>
</label>
<label>
{t.createNodeGroup}
<button
className="primary"
disabled={!nodeGroupForm.name.trim()}
onClick={() =>
void runAction(
async () => {
await client.createNodeGroup(selectedClusterId, {
name: nodeGroupForm.name,
parentGroupId: nodeGroupForm.parentGroupId || null,
});
setNodeGroupForm({ name: "", parentGroupId: "" });
},
t.nodeGroupCreated,
)
}
>
{t.createNodeGroup}
</button>
</label>
</FormGrid>
<div className="nodeList">
{visibleNodeTreeRows.map((row) => {
if (row.kind === "group") {
const isCollapsed = collapsedNodeGroupKeys.includes(row.key);
return (
<div className="nodeListGroup" key={row.key} style={{ paddingLeft: `${row.depth * 18}px` }}>
<div className="nodeListMain">
<strong>{row.label}</strong>
{row.groupId && <span>{groupPathLabelById(row.groupId, nodeGroups)}</span>}
</div>
<div className="actions">
<span className="pill">{row.count}</span>
<button className="ghost" onClick={() => setCollapsedNodeGroupKeys(toggleArrayValue(collapsedNodeGroupKeys, row.key))}>
{isCollapsed ? t.expandGroup : t.collapseGroup}
</button>
{row.groupId && (
<button className="ghost" onClick={() => setNodeGroupForm({ name: "", parentGroupId: row.groupId || "" })}>
{t.createSubgroup}
</button>
)}
</div>
</div>
);
}
const entry = row.entry;
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
const displayNode = activeMembership?.node || entry.node;
const isActiveMember = activeMembership?.node.membership_status === "active";
const isRevokedMember = activeMembership?.node.membership_status === "revoked";
return (
<div className="nodeListRow" key={row.key} style={{ marginLeft: `${row.depth * 18}px` }}>
<div className="nodeListMain">
<strong>{displayNode.name}</strong>
<span>{displayNode.node_key}</span>
</div>
<Status value={displayNode.health_status} />
<span className="muted">{displayNode.reported_version || "версия неизвестна"}</span>
<span className="muted">{formatDate(displayNode.last_seen_at)}</span>
{activeMembership ? <Status value={activeMembership.node.membership_status} /> : <span className="muted">{t.notMemberOfActiveCluster}</span>}
<div className="actions">
<button
onClick={() => {
setNodeInfoDialog(entry);
setNodeInfoMode("details");
}}
>
{t.nodeDetails}
</button>
{isActiveMember ? (
<button
className="primary"
onClick={() => {
setNodeInfoDialog(entry);
setNodeInfoMode("manage");
}}
>
{t.manageNode}
</button>
) : isRevokedMember ? (
<span className="muted">{t.revokedMembership}</span>
) : (
<button
className="primary"
onClick={() => {
setAttachNodeDialog(entry);
setAttachNodeRoles([]);
}}
>
{t.connectExistingNode}
</button>
)}
</div>
</div>
);
})}
{visibleNodeTreeRows.length === 0 && <EmptyState title={t.noNodesTitle} text={t.noNodesByFilter} />}
</div>
</article>
{attachNodeDialog && (
<div className="modalBackdrop" role="presentation">
<div className="modalCard" role="dialog" aria-modal="true" aria-labelledby="attach-node-title">
<div className="cardHead">
<div>
<h3 id="attach-node-title">{t.connectExistingNodeTitle}</h3>
<p className="muted">{t.connectExistingNodeText}</p>
</div>
<button className="ghost" onClick={() => setAttachNodeDialog(null)}>
{t.cancel}
</button>
</div>
<div className="stateList">
<StateLine label="Узел" value={attachNodeDialog.node.name} />
<StateLine label="Node key" value={attachNodeDialog.node.node_key} />
<StateLine label={t.activeCluster} value={selectedCluster?.name || selectedClusterId} />
</div>
<div className="checkGrid">
{roleOptions.map((role) => (
<label className="checkLine" key={role}>
<input type="checkbox" checked={attachNodeRoles.includes(role)} onChange={() => setAttachNodeRoles(toggleArrayValue(attachNodeRoles, role))} />
{role}
</label>
))}
</div>
<div className="actions">
<button
className="primary"
onClick={() =>
void runAction(
async () => {
await client.attachExistingNode(selectedClusterId, attachNodeDialog.node.id, attachNodeRoles);
setAttachNodeDialog(null);
setAttachNodeRoles([]);
setNodeViewScope("cluster");
},
"Узел подключен к активному кластеру.",
)
}
>
{t.connectWithRoles}
</button>
<button onClick={() => setAttachNodeDialog(null)}>{t.cancel}</button>
</div>
</div>
</div>
)}
{nodeInfoDialog &&
(() => {
const activeMembership = nodeInfoDialog.memberships.find((membership) => membership.cluster.id === selectedClusterId);
const node = activeMembership?.node || nodeInfoDialog.node;
const latestHeartbeat = activeMembership ? (heartbeatsByNode[node.id] || [])[0] : undefined;
const activeRoles = activeMembership ? (rolesByNode[node.id] || []).filter((role) => role.status === "active") : [];
const desiredWorkloads = activeMembership ? desiredWorkloadsByNode[node.id] || [] : [];
const observedWorkloads = activeMembership ? workloadsByNode[node.id] || [] : [];
return (
<div className="modalBackdrop" role="presentation">
<div className="modalCard wide" role="dialog" aria-modal="true" aria-labelledby="node-info-title">
<div className="cardHead">
<div>
<h3 id="node-info-title">
{nodeInfoMode === "manage" ? t.manageNode : t.nodeDetails}: {node.name}
</h3>
<p className="muted">{node.node_key}</p>
</div>
<button
className="ghost"
onClick={() => {
setNodeInfoDialog(null);
setNodeInfoMode("details");
}}
>
{t.close}
</button>
</div>
<section className="nodePanel">
<h4>{t.nodeIdentity}</h4>
<div className="stateList">
<StateLine label="Node ID" value={shortId(node.id)} />
<StateLine label="Ключ узла" value={node.node_key} />
<StateLine label="Тип владения" value={statusLabel(node.ownership_type)} />
<StateLine label="Owner org" value={shortId(node.owner_organization_id)} />
<StateLine label="Регистрация" value={statusLabel(node.registration_status)} />
<StateLine label="Здоровье" value={statusLabel(node.health_status)} />
<StateLine label="Версия" value={node.reported_version || "неизвестно"} />
<StateLine label="Последний сигнал" value={formatDate(node.last_seen_at)} />
</div>
</section>
<section className="nodePanel">
<h4>{t.clusterMemberships}</h4>
<div className="membershipList">
{nodeInfoDialog.memberships.map((membership) => (
<span key={membership.cluster.id} className={membership.cluster.id === selectedClusterId ? "pill good" : "pill"}>
{membership.cluster.name}: {statusLabel(membership.node.membership_status)}
</span>
))}
</div>
</section>
{activeMembership ? (
<>
<section className="nodePanel">
<h4>{t.activeClusterScope}</h4>
<div className="stateList">
<StateLine label="Участие" value={statusLabel(node.membership_status)} />
<StateLine label="Сегмент" value={statusLabel(node.partition_state)} />
<StateLine label="Группа" value={node.node_group_name || t.ungroupedNodes} />
<StateLine label="Ролей" value={String(activeRoles.length)} />
<StateLine label="Desired-сервисов" value={String(desiredWorkloads.length)} />
<StateLine label="Observed-сервисов" value={String(observedWorkloads.length)} />
</div>
</section>
{nodeInfoMode === "details" && (
<>
<section className="nodePanel">
<h4>{t.nodeRoles}</h4>
<div className="serviceTags">
{activeRoles.length === 0 && <p className="muted">{t.noRoles}</p>}
{activeRoles.map((role) => (
<div className="serviceTag" key={role.id}>
<strong>{role.role}</strong>
<span>{role.organization_id ? `organization: ${shortId(role.organization_id)}` : "cluster-wide"}</span>
<small>{formatDate(role.assigned_at)}</small>
<span className={`pill ${capabilityPillTone(role.role, latestHeartbeat)}`}>{capabilityLabel(role.role, latestHeartbeat, t)}</span>
</div>
))}
</div>
</section>
<section className="nodePanel">
<h4>{t.desiredServices}</h4>
<div className="serviceTags">
{desiredWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
{desiredWorkloads.map((workload) => (
<div className="serviceTag" key={`${workload.service_type}-${workload.runtime_mode}`}>
<strong>{workload.service_type}</strong>
<span>
{statusLabel(workload.desired_state)} / {workload.runtime_mode}
</span>
<small>{workload.version || "версия не закреплена"}</small>
</div>
))}
</div>
</section>
<section className="nodePanel">
<h4>{t.observedServices}</h4>
<div className="serviceTags">
{observedWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
{observedWorkloads.map((workload) => (
<div className="serviceTag" key={workload.id}>
<strong>{workload.service_type}</strong>
<span>
{statusLabel(workload.reported_state)} / {workload.runtime_mode}
</span>
<small>{formatDate(workload.observed_at)}</small>
</div>
))}
</div>
</section>
<section className="nodePanel">
<h4>{t.nodeTelemetry}</h4>
<TelemetryStrip items={telemetryByNode[node.id] || []} emptyText={t.noTelemetry} />
</section>
<section className="nodePanel">
<h4>{t.recentHeartbeats}</h4>
<DataTable
columns={["состояние", "версия", "mesh recovery", "mesh intents", "rv leases", "path decisions", "route gen", "route health", "наблюдение"]}
rows={(heartbeatsByNode[node.id] || []).slice(0, 5).map((heartbeat) => [
heartbeat.health_status,
heartbeat.reported_version || "неизвестно",
meshRecoverySummary(heartbeat),
meshConnectionIntentSummary(heartbeat),
meshRendezvousLeaseSummary(heartbeat),
meshRoutePathDecisionSummary(heartbeat),
meshRouteGenerationSummary(heartbeat),
meshRouteHealthConfigSummary(heartbeat),
formatDate(heartbeat.observed_at),
])}
/>
</section>
</>
)}
{nodeInfoMode === "manage" && (
<section className="nodePanel">
<h4>{t.nodeFunctions}</h4>
<p className="muted">{t.nodeFunctionsText}</p>
<label className="wideLabel">
{t.organizationScopeForEnable}
<input value={roleOrgScope} onChange={(event) => setRoleOrgScope(event.target.value)} placeholder={t.clusterWideRolePlaceholder} />
</label>
<div className="functionList">
{roleOptions.map((role) => {
const activeRole = activeRoles.find((assignment) => assignment.role === role);
const desired = desiredWorkloads.find((workload) => workload.service_type === role);
const observed = observedWorkloads.find((workload) => workload.service_type === role);
const capabilityTone = capabilityPillTone(role, latestHeartbeat);
const desiredState = desired?.desired_state || "disabled";
const observedState = observed?.reported_state || "missing";
const enabled = Boolean(activeRole) && desiredState === "enabled";
return (
<div className="functionRow" key={role}>
<div className="nodeListMain">
<strong>{role}</strong>
<span>{capabilityOptionLabel(role, latestHeartbeat, language)}</span>
</div>
<span className={`pill ${activeRole ? "good" : ""}`}>{activeRole ? t.permissionGranted : t.permissionDenied}</span>
<Status value={desiredState} />
<Status value={observedState} />
<span className={`pill ${capabilityTone}`}>{capabilityLabel(role, latestHeartbeat, t)}</span>
<div className="actions">
<button
className={enabled ? "" : "primary"}
disabled={enabled}
onClick={() =>
void runAction(async () => {
if (!activeRole) {
await client.setRoleStatus(selectedClusterId, node.id, role, "active", roleOrgScope || undefined);
}
await client.setDesiredWorkload(selectedClusterId, node.id, role, {
desiredState: "enabled",
runtimeMode: "container",
config: {},
environment: {},
});
}, `${role}: функция включена.`)
}
>
{t.enableFunction}
</button>
<button
disabled={!activeRole && desiredState !== "enabled"}
onClick={() =>
void runAction(async () => {
await client.setDesiredWorkload(selectedClusterId, node.id, role, {
desiredState: "disabled",
runtimeMode: desired?.runtime_mode || "container",
config: desired?.config || {},
environment: desired?.environment || {},
});
if (activeRole) {
await client.setRoleStatus(selectedClusterId, node.id, role, "disabled", activeRole.organization_id || undefined);
}
}, `${role}: функция выключена.`)
}
>
{t.disableFunction}
</button>
</div>
</div>
);
})}
</div>
<div className="actions">
<select
value={node.node_group_id || ""}
onChange={(event) =>
void runAction(
() => client.assignNodeGroup(selectedClusterId, node.id, event.target.value || null),
event.target.value ? "Узел перемещен в группу." : "Узел убран из группы.",
)
}
>
<option value="">{t.ungroupedNodes}</option>
{nodeGroups.map((group) => (
<option key={group.id} value={group.id}>
{groupPathLabel(group, nodeGroups)}
</option>
))}
</select>
</div>
<div className="actions">
<button
onClick={() =>
confirmHighRisk(`Отключить участие узла ${node.name}`) &&
void runAction(
() => client.disableMembership(selectedClusterId, node.id, "Отключено из панели владельца платформы."),
"Участие узла отключено.",
)
}
>
Отключить участие
</button>
<button
className="danger"
onClick={() =>
confirmHighRisk(`Отозвать identity узла ${node.name}`) &&
void runAction(
() => client.revokeNodeIdentity(selectedClusterId, node.id, "Отозвано из панели владельца платформы."),
"Identity узла отозван.",
)
}
>
Отозвать identity
</button>
</div>
</section>
)}
</>
) : (
<section className="nodePanel">
<h4>{t.noActiveClusterMembership}</h4>
<div className="actions">
<button
className="primary"
onClick={() => {
setAttachNodeDialog(nodeInfoDialog);
setAttachNodeRoles([]);
setNodeInfoDialog(null);
}}
>
{t.connectExistingNode}
</button>
</div>
</section>
)}
</div>
</div>
);
})()}
{false && (nodeViewScope === "all" ? (
<article className="card span2">
<h3>{t.allNodes}</h3>
<p className="muted">{t.nodeGlobalInventoryText}</p>
<FormGrid>
<label>
{t.nodeSearch}
<input value={allNodeSearch} onChange={(event) => setAllNodeSearch(event.target.value)} placeholder="имя, ключ, кластер, статус" />
</label>
<label>
{t.groupNodesBy}
<select value={allNodeGroupBy} onChange={(event) => setAllNodeGroupBy(event.target.value as typeof allNodeGroupBy)}>
<option value="membership">{t.groupByMembership}</option>
<option value="health">{t.groupByHealth}</option>
<option value="ownership">{t.groupByOwnership}</option>
<option value="cluster_count">{t.groupByClusterCount}</option>
</select>
</label>
</FormGrid>
<div className="stack">
{groupedAllNodeInventory.map((group) => (
<section className="nodePanel" key={group.label}>
<div className="cardHead">
<h4>{group.label}</h4>
<span className="pill">{group.items.length}</span>
</div>
<DataTable
columns={["узел", "ключ", t.currentClusterMembership, t.clusterMemberships, "здоровье", "версия", "последний сигнал", "действие"]}
rows={group.items.map((entry) => {
const { node, memberships } = entry;
const activeMembership = memberships.find((membership) => membership.cluster.id === selectedClusterId);
const isActiveMember = activeMembership?.node.membership_status === "active";
const isRevokedMember = activeMembership?.node.membership_status === "revoked";
return [
node.name,
node.node_key,
activeMembership ? <Status value={activeMembership.node.membership_status} /> : <span className="muted">{t.notMemberOfActiveCluster}</span>,
<div className="membershipList">
{memberships.map((membership) => (
<span key={`${membership.cluster.id}-${membership.node.id}`} className={membership.cluster.id === selectedClusterId ? "pill good" : "pill"}>
{membership.cluster.name}: {statusLabel(membership.node.membership_status)}
</span>
))}
</div>,
statusLabel(node.health_status),
node.reported_version || "неизвестно",
formatDate(node.last_seen_at),
<div className="actions" key={`${selectedClusterId}-${node.id}`}>
{isActiveMember ? (
<button
onClick={() => {
setNodeViewScope("cluster");
}}
>
{t.manageInCluster}
</button>
) : isRevokedMember ? (
<span className="muted">{t.revokedMembership}</span>
) : (
<button
className="primary"
onClick={() => {
setAttachNodeDialog(entry);
setAttachNodeRoles([]);
}}
>
{t.connectExistingNode}
</button>
)}
</div>,
];
})}
/>
</section>
))}
{groupedAllNodeInventory.length === 0 && <EmptyState title="Нет узлов" text="По текущему фильтру узлы не найдены." />}
</div>
{attachNodeDialog && (
<div className="modalBackdrop" role="presentation">
<div className="modalCard" role="dialog" aria-modal="true" aria-labelledby="attach-node-title">
<div className="cardHead">
<div>
<h3 id="attach-node-title">{t.connectExistingNodeTitle}</h3>
<p className="muted">{t.connectExistingNodeText}</p>
</div>
<button className="ghost" onClick={() => setAttachNodeDialog(null)}>
{t.cancel}
</button>
</div>
<div className="stateList">
<StateLine label="Узел" value={attachNodeDialog!.node.name} />
<StateLine label="Node key" value={attachNodeDialog!.node.node_key} />
<StateLine label={t.activeCluster} value={selectedCluster?.name || selectedClusterId} />
</div>
<div className="checkGrid">
{roleOptions.map((role) => (
<label className="checkLine" key={role}>
<input
type="checkbox"
checked={attachNodeRoles.includes(role)}
onChange={() => setAttachNodeRoles(toggleArrayValue(attachNodeRoles, role))}
/>
{role}
</label>
))}
</div>
<div className="actions">
<button
className="primary"
onClick={() =>
void runAction(
async () => {
await client.attachExistingNode(selectedClusterId, attachNodeDialog!.node.id, attachNodeRoles);
setAttachNodeDialog(null);
setAttachNodeRoles([]);
setNodeViewScope("cluster");
},
"Узел подключен к активному кластеру.",
)
}
>
{t.connectWithRoles}
</button>
<button onClick={() => setAttachNodeDialog(null)}>{t.cancel}</button>
</div>
</div>
</div>
)}
</article>
) : (
<>
<article className="card span2">
<h3>{t.testMode}</h3>
<p className="muted">{t.testModeText}</p>
<div className="actions">
<button
className={platformTestingFlag?.telemetry_enabled ? "primary" : ""}
onClick={() =>
void runAction(
() =>
client.updateFabricTestingFlag({
scopeType: "platform",
enabled: true,
telemetryEnabled: !platformTestingFlag?.telemetry_enabled,
syntheticLinksEnabled: Boolean(platformTestingFlag?.synthetic_links_enabled),
historyRetentionHours: platformTestingFlag?.history_retention_hours || 24,
}),
platformTestingFlag?.telemetry_enabled ? "Тестовая телеметрия платформы выключена." : "Тестовая телеметрия платформы включена.",
)
}
>
{t.enableTelemetry}: {platformTestingFlag?.telemetry_enabled ? "ВКЛ" : "ВЫКЛ"}
</button>
<button
className={platformTestingFlag?.synthetic_links_enabled ? "primary" : ""}
onClick={() =>
void runAction(
() =>
client.updateFabricTestingFlag({
scopeType: "platform",
enabled: true,
telemetryEnabled: Boolean(platformTestingFlag?.telemetry_enabled),
syntheticLinksEnabled: !platformTestingFlag?.synthetic_links_enabled,
historyRetentionHours: platformTestingFlag?.history_retention_hours || 24,
}),
platformTestingFlag?.synthetic_links_enabled ? "Тестовые связи выключены." : "Тестовые связи включены.",
)
}
>
{t.enableSyntheticLinks}: {platformTestingFlag?.synthetic_links_enabled ? "ВКЛ" : "ВЫКЛ"}
</button>
</div>
<section className="nodePanel">
<h4>{t.organizationTestFlag}</h4>
<p className="muted">
Флаг уровня организации нужен для безопасной проверки телеметрии и истории в tenant scope. Он не раскрывает внутреннюю topology
organization-панелям.
</p>
<FormGrid>
<label>
{t.organizationId}
<input value={testingOrgId} onChange={(event) => setTestingOrgId(event.target.value)} placeholder="UUID организации" />
</label>
<label className="checkLine">
<input
type="checkbox"
checked={testingOrgDraft.telemetry}
onChange={(event) => setTestingOrgDraft({ ...testingOrgDraft, telemetry: event.target.checked })}
/>
{t.enableTelemetry}
</label>
<label className="checkLine">
<input
type="checkbox"
checked={testingOrgDraft.links}
onChange={(event) => setTestingOrgDraft({ ...testingOrgDraft, links: event.target.checked })}
/>
{t.enableSyntheticLinks}
</label>
</FormGrid>
<div className="actions">
<button
disabled={!testingOrgId}
onClick={() =>
void runAction(
() =>
client.updateFabricTestingFlag({
scopeType: "organization",
scopeId: testingOrgId,
clusterId: selectedClusterId,
enabled: true,
telemetryEnabled: testingOrgDraft.telemetry,
syntheticLinksEnabled: testingOrgDraft.links,
historyRetentionHours: organizationTestingFlag?.history_retention_hours || 24,
}),
"Флаг тестирования организации сохранен.",
)
}
>
{t.saveOrganizationFlag}
</button>
{organizationTestingFlag && <Status value={organizationTestingFlag?.enabled ? "enabled" : "disabled"} />}
</div>
</section>
</article>
<article className="card span2">
<div className="cardHead">
<div>
<h3>{t.nodeGroups}</h3>
<p className="muted">
Группы принадлежат активному кластеру и могут быть вложенными. Один узел может быть в другой группе в другом кластере.
</p>
</div>
<span className="pill">{nodeGroups.length}</span>
</div>
<FormGrid>
<label>
{t.nodeGroupName}
<input value={nodeGroupForm.name} onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, name: event.target.value })} />
</label>
<label>
{t.parentNodeGroup}
<select
value={nodeGroupForm.parentGroupId}
onChange={(event) => setNodeGroupForm({ ...nodeGroupForm, parentGroupId: event.target.value })}
>
<option value="">{t.rootNodeGroup}</option>
{nodeGroups.map((group) => (
<option key={group.id} value={group.id}>
{groupPathLabel(group, nodeGroups)}
</option>
))}
</select>
</label>
</FormGrid>
<div className="actions">
<button
className="primary"
disabled={!nodeGroupForm.name.trim()}
onClick={() =>
void runAction(
async () => {
await client.createNodeGroup(selectedClusterId, {
name: nodeGroupForm.name,
parentGroupId: nodeGroupForm.parentGroupId || null,
});
setNodeGroupForm({ name: "", parentGroupId: "" });
},
"Группа узлов создана.",
)
}
>
{t.createNodeGroup}
</button>
</div>
<NodeGroupTree groups={nodeGroups} nodes={nodes} labels={t} />
</article>
{nodes.map((node) => {
const latestHeartbeat = (heartbeatsByNode[node.id] || [])[0];
const activeRoles = (rolesByNode[node.id] || []).filter((role) => role.status === "active");
const desiredWorkloads = desiredWorkloadsByNode[node.id] || [];
const observedWorkloads = workloadsByNode[node.id] || [];
return (
<article className="card nodeCard" key={node.id}>
<div className="cardHead">
<div>
<h3>{node.name}</h3>
<p className="muted">{node.node_key}</p>
</div>
<Status value={node.health_status} />
</div>
<section className="nodePanel">
<h4>{t.nodeIdentity}</h4>
<div className="stateList">
<StateLine label="Node ID" value={shortId(node.id)} />
<StateLine label="Ключ узла" value={node.node_key} />
<StateLine label="Тип владения" value={statusLabel(node.ownership_type)} />
<StateLine label="Owner org" value={shortId(node.owner_organization_id)} />
<StateLine label="Регистрация" value={statusLabel(node.registration_status)} />
</div>
</section>
<section className="nodePanel">
<h4>{t.activeClusterScope}</h4>
<p className="muted">{t.activeClusterScopeText}</p>
<div className="stateList">
<StateLine label="Участие" value={statusLabel(node.membership_status)} />
<StateLine label="Сегмент" value={statusLabel(node.partition_state)} />
<StateLine label="Здоровье" value={statusLabel(node.health_status)} />
<StateLine label="Группа" value={node.node_group_name || t.ungroupedNodes} />
<StateLine label="Версия" value={node.reported_version || "неизвестно"} />
<StateLine label="Последний сигнал" value={formatDate(node.last_seen_at)} />
<StateLine label="Ролей в кластере" value={String(activeRoles.length)} />
<StateLine label="Desired-сервисов" value={String(desiredWorkloads.length)} />
<StateLine label="Observed-сервисов" value={String(observedWorkloads.length)} />
</div>
</section>
<section className="nodePanel">
<h4>{t.nodeRoles}</h4>
<div className="serviceTags">
{activeRoles.length === 0 && <p className="muted">{t.noRoles}</p>}
{activeRoles.map((role) => (
<div className="serviceTag" key={role.id}>
<strong>{role.role}</strong>
<span>{role.organization_id ? `organization: ${shortId(role.organization_id)}` : "cluster-wide"}</span>
<small>{formatDate(role.assigned_at)}</small>
<span className={`pill ${capabilityPillTone(role.role, latestHeartbeat)}`}>
{capabilityLabel(role.role, latestHeartbeat, t)}
</span>
</div>
))}
</div>
</section>
<section className="nodePanel">
<h4>{t.desiredServices}</h4>
<div className="serviceTags">
{desiredWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
{desiredWorkloads.map((workload) => (
<div className="serviceTag" key={`${workload.service_type}-${workload.runtime_mode}`}>
<strong>{workload.service_type}</strong>
<span>{statusLabel(workload.desired_state)} / {workload.runtime_mode}</span>
<small>{workload.version || "версия не закреплена"}</small>
<span className={`pill ${capabilityPillTone(workload.service_type, latestHeartbeat)}`}>
{capabilityLabel(workload.service_type, latestHeartbeat, t)}
</span>
</div>
))}
</div>
</section>
<section className="nodePanel">
<h4>{t.observedServices}</h4>
<div className="serviceTags">
{observedWorkloads.length === 0 && <p className="muted">{t.noServices}</p>}
{observedWorkloads.map((workload) => (
<div className="serviceTag" key={workload.id}>
<strong>{workload.service_type}</strong>
<span>{statusLabel(workload.reported_state)} / {workload.runtime_mode}</span>
<small>{formatDate(workload.observed_at)}</small>
</div>
))}
</div>
</section>
<section className="nodePanel">
<h4>{t.nodeManagement}</h4>
<div className="actions">
<select
value={nodeRoleDrafts[node.id] || ""}
onChange={(event) => setNodeRoleDrafts({ ...nodeRoleDrafts, [node.id]: event.target.value })}
>
<option value="">Роль...</option>
{roleOptions.map((role) => (
<option key={role} value={role}>
{role} {capabilityOptionLabel(role, latestHeartbeat, language)}
</option>
))}
</select>
<button
disabled={!nodeRoleDrafts[node.id]}
onClick={() =>
void runAction(
() => client.assignRole(selectedClusterId, node.id, nodeRoleDrafts[node.id], roleOrgScope || undefined),
"Роль назначена.",
)
}
>
Назначить роль
</button>
<select
value={nodeWorkloadDrafts[node.id] || ""}
onChange={(event) => setNodeWorkloadDrafts({ ...nodeWorkloadDrafts, [node.id]: event.target.value })}
>
<option value="">Сервис...</option>
{roleOptions.map((role) => (
<option key={role} value={role}>
{role} {capabilityOptionLabel(role, latestHeartbeat, language)}
</option>
))}
</select>
<button
disabled={!nodeWorkloadDrafts[node.id]}
onClick={() =>
void runAction(
() =>
client.setDesiredWorkload(selectedClusterId, node.id, nodeWorkloadDrafts[node.id], {
desiredState: "enabled",
runtimeMode: "container",
config: {},
environment: {},
}),
"Желаемое состояние сервиса включено.",
)
}
>
Включить сервис
</button>
<button
disabled={!nodeWorkloadDrafts[node.id]}
onClick={() =>
void runAction(
() =>
client.setDesiredWorkload(selectedClusterId, node.id, nodeWorkloadDrafts[node.id], {
desiredState: "disabled",
runtimeMode: "container",
config: {},
environment: {},
}),
"Желаемое состояние сервиса выключено.",
)
}
>
Выключить сервис
</button>
<select
value={node.node_group_id || ""}
onChange={(event) =>
void runAction(
() => client.assignNodeGroup(selectedClusterId, node.id, event.target.value || null),
event.target.value ? "Узел перемещен в группу." : "Узел убран из группы.",
)
}
>
<option value="">{t.ungroupedNodes}</option>
{nodeGroups.map((group) => (
<option key={group.id} value={group.id}>
{groupPathLabel(group, nodeGroups)}
</option>
))}
</select>
</div>
</section>
<section className="nodePanel">
<h4>{t.testMode}</h4>
<div className="actions">
<label className="checkLine">
<input
type="checkbox"
checked={nodeTestingDrafts[node.id]?.telemetry ?? true}
onChange={(event) =>
setNodeTestingDrafts({
...nodeTestingDrafts,
[node.id]: { telemetry: event.target.checked, links: nodeTestingDrafts[node.id]?.links ?? true },
})
}
/>
{t.enableTelemetry}
</label>
<label className="checkLine">
<input
type="checkbox"
checked={nodeTestingDrafts[node.id]?.links ?? true}
onChange={(event) =>
setNodeTestingDrafts({
...nodeTestingDrafts,
[node.id]: { telemetry: nodeTestingDrafts[node.id]?.telemetry ?? true, links: event.target.checked },
})
}
/>
{t.enableSyntheticLinks}
</label>
<button
onClick={() =>
void runAction(
() =>
client.updateFabricTestingFlag({
scopeType: "node",
scopeId: node.id,
clusterId: selectedClusterId,
enabled: true,
telemetryEnabled: nodeTestingDrafts[node.id]?.telemetry ?? true,
syntheticLinksEnabled: nodeTestingDrafts[node.id]?.links ?? true,
historyRetentionHours: 24,
}),
"Флаг тестирования узла сохранен.",
)
}
>
{t.saveTestFlag}
</button>
</div>
</section>
<section className="nodePanel">
<h4>{t.nodeTelemetry}</h4>
<TelemetryStrip items={telemetryByNode[node.id] || []} emptyText={t.noTelemetry} />
</section>
<section className="nodePanel">
<h4>{t.recentHeartbeats}</h4>
<DataTable
columns={["состояние", "версия", "mesh recovery", "mesh intents", "rv leases", "path decisions", "route gen", "route health", "наблюдение"]}
rows={(heartbeatsByNode[node.id] || []).slice(0, 5).map((heartbeat) => [
heartbeat.health_status,
heartbeat.reported_version || "неизвестно",
meshRecoverySummary(heartbeat),
meshConnectionIntentSummary(heartbeat),
meshRendezvousLeaseSummary(heartbeat),
meshRoutePathDecisionSummary(heartbeat),
meshRouteGenerationSummary(heartbeat),
meshRouteHealthConfigSummary(heartbeat),
formatDate(heartbeat.observed_at),
])}
/>
</section>
<div className="actions">
<button
onClick={() =>
confirmHighRisk(`Отключить участие узла ${node.name}`) &&
void runAction(
() => client.disableMembership(selectedClusterId, node.id, "Отключено из панели владельца платформы."),
"Участие узла отключено.",
)
}
>
Отключить участие
</button>
<button
className="danger"
onClick={() =>
confirmHighRisk(`Отозвать identity узла ${node.name}`) &&
void runAction(
() => client.revokeNodeIdentity(selectedClusterId, node.id, "Отозвано из панели владельца платформы."),
"Identity узла отозван.",
)
}
>
Отозвать identity
</button>
</div>
</article>
);
})}
{nodes.length === 0 && <EmptyState title="Нет узлов" text="Одобренные node-agent появятся здесь." />}
</>
))}
</section>
)}
{activeView === "enrollment" && (
<section className="grid two">
<article className="card">
<h3>{t.joinTokenTitle}</h3>
<p className="muted">{t.joinTokenText}</p>
<FormGrid>
<label>
{t.ttlHours}
<input
type="number"
min={1}
max={720}
value={joinTokenForm.ttlHours}
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, ttlHours: Number(event.target.value) })}
/>
<small>{t.ttlHelp}</small>
</label>
<label>
{t.maxUses}
<input
type="number"
min={1}
max={100}
value={joinTokenForm.maxUses}
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, maxUses: Number(event.target.value) })}
/>
<small>{t.maxUsesHelp}</small>
</label>
<label>
{t.nodeOwnership}
<select
value={joinTokenForm.ownershipType}
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, ownershipType: event.target.value })}
>
<option value="platform_managed">platform_managed, управляется платформой</option>
<option value="customer_managed">customer_managed, управляется клиентом</option>
</select>
</label>
<label>
{t.tokenPurpose}
<input
value={joinTokenForm.purpose}
onChange={(event) => setJoinTokenForm({ ...joinTokenForm, purpose: event.target.value })}
placeholder="например: стартовый entry-node в ru-msk-1"
/>
</label>
</FormGrid>
<section className="nodePanel">
<h4>{t.suggestedRoles}</h4>
<p className="muted">
Роли можно назначить узлу после approval. Здесь мы задаем безопасную ожидаемую область token, чтобы оператор видел назначение пропуска.
</p>
<div className="checkGrid">
{roleOptions.map((role) => (
<label className="checkLine" key={role}>
<input
type="checkbox"
checked={joinTokenForm.roles.includes(role)}
onChange={() => setJoinTokenForm({ ...joinTokenForm, roles: toggleArrayValue(joinTokenForm.roles, role) })}
/>
{role}
</label>
))}
</div>
</section>
<details>
<summary>{t.generatedScope}</summary>
<p className="muted">{t.generatedScopeHelp}</p>
<pre className="codePreview">{JSON.stringify(joinTokenScope, null, 2)}</pre>
</details>
<p className="muted">{t.manualApprovalRequired}.</p>
<button
className="primary"
disabled={!selectedClusterId}
onClick={() =>
void runAction(async () => {
const token = await client.createJoinToken(selectedClusterId, {
ttlHours: joinTokenForm.ttlHours,
maxUses: joinTokenForm.maxUses,
scope: joinTokenScope,
});
setLastJoinToken(token);
}, "Join token создан.")
}
>
Создать token
</button>
{lastJoinToken && (
<div className="secretOnce">
<strong>Исходный token, возвращается один раз</strong>
<code>{lastJoinToken.token}</code>
<span className="muted">Authority key: {shortId(lastJoinToken.authority_signature?.key_fingerprint)}</span>
</div>
)}
</article>
<article className="card">
<h3>Заявки на подключение</h3>
<div className="stack">
{joinRequests.map((request) => (
<div className="requestCard" key={request.id}>
<div>
<strong>{request.node_name}</strong>
<p>{request.node_fingerprint}</p>
<Status value={request.status} />
{request.approval_signature?.key_fingerprint && (
<small className="muted">approval key {shortId(request.approval_signature.key_fingerprint)}</small>
)}
</div>
<div className="actions">
<button
disabled={request.status !== "pending"}
onClick={() => void runAction(() => client.approveJoinRequest(selectedClusterId, request.id), "Заявка одобрена.")}
>
Одобрить
</button>
<button
disabled={request.status !== "pending"}
onClick={() =>
void runAction(
() => client.rejectJoinRequest(selectedClusterId, request.id, "Отклонено из панели владельца платформы."),
"Заявка отклонена.",
)
}
>
Отклонить
</button>
</div>
</div>
))}
{joinRequests.length === 0 && <EmptyState title="Нет заявок" text="Новые подключения node-agent появятся здесь." />}
</div>
</article>
</section>
)}
{activeView === "roles" && (
<section className="stack">
<article className="card">
<h3>Область ролей</h3>
<p className="muted">Capabilities технические факты. Роли явные разрешения. Область организации может ограничивать сервисные роли.</p>
<label>
UUID организации для новых назначений ролей, опционально
<input value={roleOrgScope} onChange={(event) => setRoleOrgScope(event.target.value)} placeholder="пусто = роль на весь кластер" />
</label>
</article>
{nodes.map((node) => (
<article className="card roleRow" key={node.id}>
<div>
<h3>{node.name}</h3>
<p>{summarizeRoles(rolesByNode[node.id] || [])}</p>
</div>
<select
defaultValue=""
onChange={(event) => {
const role = event.target.value;
event.currentTarget.value = "";
if (role) {
void runAction(() => client.assignRole(selectedClusterId, node.id, role, roleOrgScope || undefined), `${role} назначена узлу ${node.name}.`);
}
}}
>
<option value="">Назначить роль...</option>
{roleOptions.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</article>
))}
</section>
)}
{activeView === "workloads" && (
<section className="grid two">
<article className="card">
<h3>Желаемое состояние сервиса</h3>
<p className="muted">Здесь задается только желаемое состояние. Runtime-исполнение остается под контролем node-agent и политик.</p>
<FormGrid>
<label>
Узел
<select value={workloadForm.nodeId} onChange={(event) => setWorkloadForm({ ...workloadForm, nodeId: event.target.value })}>
<option value="">Выберите узел...</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name}
</option>
))}
</select>
</label>
<label>
Сервис
<select value={workloadForm.serviceType} onChange={(event) => setWorkloadForm({ ...workloadForm, serviceType: event.target.value })}>
{roleOptions.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</label>
<label>
Желаемое состояние
<select value={workloadForm.desiredState} onChange={(event) => setWorkloadForm({ ...workloadForm, desiredState: event.target.value })}>
<option value="enabled">включено</option>
<option value="disabled">выключено</option>
</select>
</label>
<label>
Режим runtime
<select value={workloadForm.runtimeMode} onChange={(event) => setWorkloadForm({ ...workloadForm, runtimeMode: event.target.value })}>
<option value="container">контейнер</option>
<option value="native">нативно</option>
</select>
</label>
<label>
Версия
<input value={workloadForm.version} onChange={(event) => setWorkloadForm({ ...workloadForm, version: event.target.value })} />
</label>
</FormGrid>
<label>
Config JSON
<textarea value={workloadForm.configJson} onChange={(event) => setWorkloadForm({ ...workloadForm, configJson: event.target.value })} />
</label>
<label>
Environment JSON
<textarea
value={workloadForm.environmentJson}
onChange={(event) => setWorkloadForm({ ...workloadForm, environmentJson: event.target.value })}
/>
</label>
<button
className="primary"
disabled={!workloadForm.nodeId || !selectedClusterId}
onClick={() =>
void runAction(
() =>
client.setDesiredWorkload(selectedClusterId, workloadForm.nodeId, workloadForm.serviceType, {
desiredState: workloadForm.desiredState,
runtimeMode: workloadForm.runtimeMode,
version: workloadForm.version,
config: parseJSONObject(workloadForm.configJson, "config сервиса"),
environment: parseJSONObject(workloadForm.environmentJson, "environment сервиса"),
}),
"Желаемое состояние сервиса обновлено.",
)
}
>
Задать желаемое состояние
</button>
</article>
<article className="card">
<h3>Отчеты сервисов</h3>
<div className="stack">
{nodes.map((node) => (
<div className="workloadBlock" key={node.id}>
<strong>{node.name}</strong>
{(workloadsByNode[node.id] || []).length === 0 ? (
<p className="muted">Статус пока не получен.</p>
) : (
<DataTable
columns={["сервис", "состояние", "runtime", "наблюдение"]}
rows={(workloadsByNode[node.id] || []).map((workload) => [
workload.service_type,
workload.reported_state,
workload.runtime_mode,
formatDate(workload.observed_at),
])}
/>
)}
</div>
))}
</div>
</article>
</section>
)}
{activeView === "fabric" && (
<section className="grid two">
<article className="card span2">
<h3>Граница подготовки Fabric</h3>
<p className="muted">
Этот экран показывает synthetic/control-plane подготовку и C17Z11 boundary: production forwarding доступен только для route-bound
`fabric.control` при явном gate. Service traffic, RDP, VPN и произвольный relay здесь не включены.
</p>
<div className="signalStrip">
<Signal label="Synthetic configs" value={`${syntheticConfigEnabledCount}/${nodes.length}`} />
<Signal label="Routes" value={String(syntheticRouteCount)} />
<Signal label="Endpoints / candidates" value={`${syntheticPeerEndpointCount}/${syntheticCandidateCount}`} />
<Signal label="Peer dir / seeds" value={`${syntheticPeerDirectoryCount}/${syntheticRecoverySeedCount}`} />
<Signal label="Scoped production flag" value={productionForwardingConfigCount === 0 ? "false" : `true:${productionForwardingConfigCount}`} />
</div>
</article>
<article className="card">
<div className="cardHead">
<div>
<h3>{t.fabricEntryPoints}</h3>
<p className="muted">{t.fabricEntryPointHelp}</p>
</div>
<span className="pill">{entryPoints.length}</span>
</div>
<FormGrid>
<label>
{t.endpointName}
<input value={entryPointForm.name} onChange={(event) => setEntryPointForm({ ...entryPointForm, name: event.target.value })} />
</label>
<label>
{t.endpointType}
<select
value={entryPointForm.endpointType}
onChange={(event) => setEntryPointForm({ ...entryPointForm, endpointType: event.target.value })}
>
<option value="client_access">client_access</option>
<option value="admin">admin</option>
<option value="api">api</option>
<option value="other">other</option>
</select>
</label>
<label className="span2">
{t.publicEndpoint}
<input
placeholder="wss://entry.example.com"
value={entryPointForm.publicEndpoint}
onChange={(event) => setEntryPointForm({ ...entryPointForm, publicEndpoint: event.target.value })}
/>
</label>
</FormGrid>
<div className="actions">
<button
className="primary"
disabled={!entryPointForm.name.trim()}
onClick={() =>
void runAction(async () => {
await client.createFabricEntryPoint(selectedClusterId, {
name: entryPointForm.name,
endpointType: entryPointForm.endpointType,
publicEndpoint: entryPointForm.publicEndpoint || null,
});
setEntryPointForm({ name: "", endpointType: "client_access", publicEndpoint: "" });
}, "Точка входа создана.")
}
>
{t.createEntryPoint}
</button>
</div>
<div className="stack">
{entryPoints.map((item) => {
const assignedNodes = entryPointNodesById[item.id] || [];
return (
<section className="nodePanel" key={item.id}>
<div className="cardHead">
<div>
<h4>{item.name}</h4>
<p className="muted">
{item.endpoint_type} · {item.public_endpoint || t.addressNotSet}
</p>
</div>
<Status value={item.status} />
</div>
<h5>{t.endpointNodes}</h5>
{assignedNodes.length === 0 ? (
<p className="muted">{t.assignedNodesEmpty}</p>
) : (
<div className="membershipList">
{assignedNodes.map((assignment) => (
<span className={assignment.status === "active" ? "pill good" : "pill"} key={`${item.id}-${assignment.node_id}`}>
{nodeName(nodes, assignment.node_id)} · {statusLabel(assignment.status)} · p{assignment.priority}
</span>
))}
</div>
)}
<div className="actions">
<select
value={entryPointNodeDrafts[item.id] || ""}
onChange={(event) => setEntryPointNodeDrafts({ ...entryPointNodeDrafts, [item.id]: event.target.value })}
>
<option value="">{t.selectNode}</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name}
</option>
))}
</select>
<button
disabled={!entryPointNodeDrafts[item.id]}
onClick={() =>
void runAction(
() => client.setFabricEntryPointNode(selectedClusterId, item.id, entryPointNodeDrafts[item.id], { status: "active" }),
"Узел назначен точке входа.",
)
}
>
{t.assignEndpointNode}
</button>
</div>
</section>
);
})}
{entryPoints.length === 0 && <EmptyState title={t.fabricEntryPoints} text={t.entryPointsEmpty} />}
</div>
</article>
<article className="card">
<div className="cardHead">
<div>
<h3>{t.fabricEgressPools}</h3>
<p className="muted">{t.fabricEgressPoolHelp}</p>
</div>
<span className="pill">{egressPools.length}</span>
</div>
<FormGrid>
<label>
{t.endpointName}
<input value={egressPoolForm.name} onChange={(event) => setEgressPoolForm({ ...egressPoolForm, name: event.target.value })} />
</label>
<label>
{t.description}
<input
value={egressPoolForm.description}
onChange={(event) => setEgressPoolForm({ ...egressPoolForm, description: event.target.value })}
/>
</label>
<label className="span2">
{t.routeScope}
<textarea
rows={5}
value={egressPoolForm.routeScope}
onChange={(event) => setEgressPoolForm({ ...egressPoolForm, routeScope: event.target.value })}
/>
</label>
</FormGrid>
<div className="actions">
<button
className="primary"
disabled={!egressPoolForm.name.trim()}
onClick={() =>
void runAction(async () => {
const routeScope = parseJSONObject(egressPoolForm.routeScope, "Route scope JSON");
await client.createFabricEgressPool(selectedClusterId, {
name: egressPoolForm.name,
description: egressPoolForm.description || null,
routeScope,
});
setEgressPoolForm({ name: "", description: "", routeScope: "{\n \"routes\": []\n}" });
}, "Выходная зона создана.")
}
>
{t.createEgressPool}
</button>
</div>
<div className="stack">
{egressPools.map((item) => {
const assignedNodes = egressPoolNodesById[item.id] || [];
return (
<section className="nodePanel" key={item.id}>
<div className="cardHead">
<div>
<h4>{item.name}</h4>
<p className="muted">{item.description || t.descriptionNotSet}</p>
</div>
<Status value={item.status} />
</div>
<p className="muted">{JSON.stringify(item.route_scope || {})}</p>
<h5>{t.endpointNodes}</h5>
{assignedNodes.length === 0 ? (
<p className="muted">{t.assignedNodesEmpty}</p>
) : (
<div className="membershipList">
{assignedNodes.map((assignment) => (
<span className={assignment.status === "active" ? "pill good" : "pill"} key={`${item.id}-${assignment.node_id}`}>
{nodeName(nodes, assignment.node_id)} · {statusLabel(assignment.status)} · p{assignment.priority}
</span>
))}
</div>
)}
<div className="actions">
<select
value={egressPoolNodeDrafts[item.id] || ""}
onChange={(event) => setEgressPoolNodeDrafts({ ...egressPoolNodeDrafts, [item.id]: event.target.value })}
>
<option value="">{t.selectNode}</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name}
</option>
))}
</select>
<button
disabled={!egressPoolNodeDrafts[item.id]}
onClick={() =>
void runAction(
() => client.setFabricEgressPoolNode(selectedClusterId, item.id, egressPoolNodeDrafts[item.id], { status: "active" }),
"Узел назначен выходной зоне.",
)
}
>
{t.assignEndpointNode}
</button>
</div>
</section>
);
})}
{egressPools.length === 0 && <EmptyState title={t.fabricEgressPools} text={t.egressPoolsEmpty} />}
</div>
</article>
<article className="card span2">
<div className="cardHead">
<div>
<h3>{t.fabricMap}</h3>
<p className="muted">
Визуальный слой показывает, какие узлы живы, какие сервисы на них назначены и какие тестовые наблюдения связей проходят между ними.
</p>
</div>
<Status value={platformTestingFlag?.synthetic_links_enabled ? "enabled" : "disabled"} />
</div>
<FabricTopology
nodes={nodes}
links={meshLinks}
entryPoints={entryPoints}
entryPointNodesById={entryPointNodesById}
egressPools={egressPools}
egressPoolNodesById={egressPoolNodesById}
rolesByNode={rolesByNode}
workloadsByNode={workloadsByNode}
telemetryByNode={telemetryByNode}
labels={t}
emptyText={t.noLinks}
/>
</article>
<article className="card span2">
<div className="cardHead">
<div>
<h3>Synthetic mesh config</h3>
<p className="muted">
Node-scoped config from Control Plane. Endpoint candidates and scoring inputs are visible to the platform owner only; production
forwarding for service traffic must remain disabled here.
</p>
</div>
<span className={productionForwardingConfigCount === 0 ? "pill good" : "pill bad"}>
production_forwarding={productionForwardingConfigCount === 0 ? "false" : "true"}
</span>
</div>
<DataTable
columns={[
"узел",
"config",
"routes",
"peer endpoints",
"candidates",
"peer dir",
"recovery seeds",
"rendezvous leases",
"relay policy",
"path decisions",
"authority",
"scoped production",
]}
rows={nodes.map((node) => {
const config = syntheticMeshConfigsByNode[node.id];
return [
node.name,
config ? (config.enabled ? "enabled" : "disabled") : "не загружен",
String(config?.routes.length ?? 0),
String(Object.keys(config?.peer_endpoints || {}).length),
String(config ? countPeerEndpointCandidates(config) : 0),
String(config?.peer_directory?.length ?? 0),
String(config?.recovery_seeds?.length ?? 0),
String(config?.rendezvous_leases?.length ?? 0),
rendezvousRelayPolicySummary(config),
routePathDecisionSummary(config),
config?.authority_required ? shortId(config.authority_signature?.key_fingerprint) : "не требуется",
config?.production_forwarding ? "true" : "false",
];
})}
/>
<p className="muted">
Health-aware scoring не выбирает service route и не открывает service-соединения. C17Z19 показывает control-plane route/path
decisions, route generation status, synthetic route-health effective path и relay feedback scoring, но не переносит
RDP/VPN/file/video/service payload.
</p>
</article>
<article className="card span2">
<h3>{t.servicePlacement}</h3>
<DataTable
columns={["узел", "здоровье", "роли", "желаемые / reported сервисы", "последний heartbeat"]}
rows={nodes.map((node) => [
node.name,
node.health_status,
summarizeRoles(rolesByNode[node.id] || []),
summarizeWorkloads(workloadsByNode[node.id] || []),
formatDate((heartbeatsByNode[node.id] || [])[0]?.observed_at || node.last_seen_at),
])}
/>
</article>
<article className="card">
<h3>{t.trafficFlow}</h3>
<DataTable
columns={["источник", "цель", "тип", "route/path", "статус", "задержка", "качество", "наблюдение"]}
rows={meshLinks.map((link) => [
nodeName(nodes, link.source_node_id),
nodeName(nodes, link.target_node_id),
meshLinkObservationLabel(link),
meshLinkRoutePathSummary(link, nodes),
link.link_status,
link.latency_ms == null ? "н/д" : `${link.latency_ms} мс`,
link.quality_score == null ? "н/д" : `${link.quality_score}/100`,
formatDate(link.observed_at),
])}
/>
</article>
<article className="card">
<h3>Политики QoS</h3>
<DataTable
columns={["класс", "приоритет", "надежность", "политика сброса"]}
rows={qosPolicies.map((policy) => [
policy.service_class,
String(policy.priority),
policy.reliability_mode,
policy.drop_policy,
])}
/>
</article>
</section>
)}
{activeView === "vpn" && (
<section className="grid two">
<article className="card">
<h3>Создать желаемое состояние VPN-подключения</h3>
<p className="muted">Только control-plane. Здесь не выполняются TUN/TAP, маршруты, DNS, firewall, QoS или packet forwarding.</p>
<FormGrid>
<label>
ID организации
<input value={vpnForm.organizationId} onChange={(event) => setVPNForm({ ...vpnForm, organizationId: event.target.value })} />
</label>
<label>
Название
<input value={vpnForm.name} onChange={(event) => setVPNForm({ ...vpnForm, name: event.target.value })} />
</label>
<label>
Протокол
<select value={vpnForm.protocolFamily} onChange={(event) => setVPNForm({ ...vpnForm, protocolFamily: event.target.value })}>
<option value="generic">generic</option>
<option value="wireguard">wireguard</option>
<option value="ipsec">ipsec</option>
<option value="openvpn">openvpn</option>
</select>
</label>
<label>
Желаемое состояние
<select value={vpnForm.desiredState} onChange={(event) => setVPNForm({ ...vpnForm, desiredState: event.target.value })}>
<option value="disabled">выключено</option>
<option value="enabled">включено</option>
</select>
</label>
<label>
Ссылка на credential
<input value={vpnForm.credentialRef} onChange={(event) => setVPNForm({ ...vpnForm, credentialRef: event.target.value })} />
</label>
</FormGrid>
<label>
Целевой endpoint JSON
<textarea value={vpnForm.targetEndpointJson} onChange={(event) => setVPNForm({ ...vpnForm, targetEndpointJson: event.target.value })} />
</label>
<label>
Политика разрешенных узлов JSON
<textarea
value={vpnForm.allowedNodePolicyJson}
onChange={(event) => setVPNForm({ ...vpnForm, allowedNodePolicyJson: event.target.value })}
/>
</label>
<details>
<summary>Расширенные routing / QoS / placement JSON</summary>
<label>
Использование маршрутизации JSON
<textarea value={vpnForm.routingUsageJson} onChange={(event) => setVPNForm({ ...vpnForm, routingUsageJson: event.target.value })} />
</label>
<label>
Политика маршрута JSON
<textarea value={vpnForm.routePolicyJson} onChange={(event) => setVPNForm({ ...vpnForm, routePolicyJson: event.target.value })} />
</label>
<label>
Политика QoS JSON
<textarea value={vpnForm.qosPolicyJson} onChange={(event) => setVPNForm({ ...vpnForm, qosPolicyJson: event.target.value })} />
</label>
<label>
Политика размещения JSON
<textarea value={vpnForm.placementPolicyJson} onChange={(event) => setVPNForm({ ...vpnForm, placementPolicyJson: event.target.value })} />
</label>
</details>
<button
className="primary"
disabled={!selectedClusterId || !vpnForm.organizationId || !vpnForm.name}
onClick={() =>
void runAction(
() =>
client.createVPNConnection(selectedClusterId, {
organizationId: vpnForm.organizationId,
name: vpnForm.name,
protocolFamily: vpnForm.protocolFamily,
credentialRef: vpnForm.credentialRef || null,
desiredState: vpnForm.desiredState,
targetEndpoint: parseJSONObject(vpnForm.targetEndpointJson, "target endpoint"),
allowedNodePolicy: parseJSONObject(vpnForm.allowedNodePolicyJson, "allowed node policy"),
routingUsage: parseJSONArray(vpnForm.routingUsageJson, "routing usage"),
routePolicy: parseJSONObject(vpnForm.routePolicyJson, "route policy"),
qosPolicy: parseJSONObject(vpnForm.qosPolicyJson, "qos policy"),
placementPolicy: parseJSONObject(vpnForm.placementPolicyJson, "placement policy"),
}),
"Желаемое состояние VPN создано.",
)
}
>
Создать желаемое состояние VPN
</button>
</article>
<article className="card">
<div className="cardHead">
<div>
<h3>VPN-подключения</h3>
<p className="muted">Только cluster-managed желаемое состояние. Runtime здесь намеренно не реализован.</p>
</div>
<button
onClick={() =>
void runAction(async () => {
const expired = await client.expireStaleVPNLeases(selectedClusterId);
setNotice(`Истекшие VPN lease: ${expired.length}.`);
}, "Stale VPN lease проверены.")
}
>
Проверить stale lease
</button>
</div>
<div className="stack">
{vpnConnections.map((connection) => (
<div className="vpnCard" key={connection.id}>
<div>
<strong>{connection.name}</strong>
<p className="muted">
{connection.protocol_family} / {connection.mode} / организация {shortId(connection.organization_id)}
</p>
<Status value={connection.desired_state} />
<Status value={connection.status} />
</div>
<div className="stateList">
<StateLine label="Секрет" value={connection.credential_ref ? "задан" : "не задан"} />
<StateLine label="Активный lease" value={vpnLeases[connection.id] ? shortId(vpnLeases[connection.id]?.owner_node_id) : "нет"} />
<StateLine label="Обновлено" value={formatDate(connection.updated_at)} />
</div>
<div className="actions">
<button
disabled={connection.desired_state === "enabled"}
onClick={() =>
void runAction(
() => client.updateVPNConnectionDesiredState(selectedClusterId, connection.id, "enabled"),
"Желаемое состояние VPN включено.",
)
}
>
Включить
</button>
<button
disabled={connection.desired_state === "disabled"}
onClick={() =>
void runAction(
() => client.updateVPNConnectionDesiredState(selectedClusterId, connection.id, "disabled"),
"Желаемое состояние VPN выключено.",
)
}
>
Выключить
</button>
</div>
</div>
))}
{vpnConnections.length === 0 && <EmptyState title="Нет желаемого состояния VPN" text="Control-plane записи C18 появятся здесь." />}
</div>
</article>
</section>
)}
{activeView === "org-safe" && (
<section className="card">
<h3>Безопасная проекция организации</h3>
<p className="muted">Это preview tenant-safe данных. Здесь нельзя показывать core mesh topology, peer cache, route cache или секреты.</p>
<div className="inlineForm">
<input value={organizationId} onChange={(event) => setOrganizationId(event.target.value)} placeholder="UUID организации" />
<button
disabled={!organizationId}
onClick={() =>
void runAction(async () => {
setOrganizationSummary(await client.getOrganizationAdminSummary(organizationId));
}, "Сводка организации загружена.")
}
>
Загрузить безопасную сводку
</button>
</div>
{organizationSummary && (
<div className="grid three">
<Metric label="Ресурсы" value={organizationSummary.resource_count} tone="steel" />
<Metric label="Активные сессии" value={organizationSummary.active_session_count} tone="green" />
<article className="card">
<h4>Раскрытие topology</h4>
<p>{organizationSummary.topology_exposure}</p>
</article>
<article className="card span3">
<h4>Сервисные точки входа</h4>
<DataTable columns={["протокол", "количество"]} rows={organizationSummary.service_endpoints.map((item) => [item.protocol, String(item.count)])} />
</article>
</div>
)}
</section>
)}
{activeView === "audit" && (
<section className="card">
<h3>Аудит кластера</h3>
<DataTable
columns={["событие", "цель", "actor", "создано"]}
rows={audit.map((event) => [
event.event_type,
`${event.target_type}${event.target_id ? `:${shortId(event.target_id)}` : ""}`,
event.actor_user_id ? shortId(event.actor_user_id) : "system",
formatDate(event.created_at),
])}
/>
</section>
)}
</section>
</main>
);
}
function Metric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<article className={`metric ${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</article>
);
}
function Signal({ label, value }: { label: string; value: string }) {
return (
<div className="signal">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function Status({ value }: { value: string }) {
return <span className={`status ${value.replace(/_/g, "-")}`}>{statusLabel(value)}</span>;
}
function StateLine({ label, value }: { label: string; value: string }) {
return (
<div className="stateLine">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function EmptyState({ title, text }: { title: string; text: string }) {
return (
<article className="empty">
<h3>{title}</h3>
<p>{text}</p>
</article>
);
}
function TelemetryStrip({ items, emptyText }: { items: NodeTelemetryObservation[]; emptyText: string }) {
if (items.length === 0) {
return <EmptyState title={emptyText} text="Тестовая телеметрия появится здесь после отчета node-agent." />;
}
const ordered = [...items].reverse().slice(-24);
const latest = items[0];
const maxMemory = Math.max(...ordered.map((item) => item.memory_used_bytes || 0), 1);
return (
<div className="telemetryBox">
<div className="signalStrip compact">
<Signal label="Память" value={`${formatBytes(latest.memory_used_bytes)} / ${formatBytes(latest.memory_total_bytes)}`} />
<Signal label="Процессор" value={latest.cpu_percent == null ? "н/д" : `${latest.cpu_percent.toFixed(1)}%`} />
<Signal label="Процессы" value={latest.process_count == null ? "н/д" : String(latest.process_count)} />
<Signal label="Обновлено" value={formatDate(latest.observed_at)} />
</div>
<div className="sparkline" aria-label="memory telemetry">
{ordered.map((item) => (
<span key={item.id} style={{ height: `${Math.max(8, Math.round(((item.memory_used_bytes || 0) / maxMemory) * 100))}%` }} />
))}
</div>
</div>
);
}
function FabricTopology({
nodes,
links,
entryPoints,
entryPointNodesById,
egressPools,
egressPoolNodesById,
rolesByNode,
workloadsByNode,
telemetryByNode,
labels,
emptyText,
}: {
nodes: ClusterNode[];
links: MeshLink[];
entryPoints: FabricEntryPoint[];
entryPointNodesById: Record<string, FabricEntryPointNode[]>;
egressPools: FabricEgressPool[];
egressPoolNodesById: Record<string, FabricEgressPoolNode[]>;
rolesByNode: Record<string, RoleAssignment[]>;
workloadsByNode: Record<string, WorkloadStatus[]>;
telemetryByNode: Record<string, NodeTelemetryObservation[]>;
labels: Record<string, string>;
emptyText: string;
}) {
if (nodes.length === 0) {
return <EmptyState title="Нет узлов" text="Одобренные node-agent появятся на карте после первого heartbeat." />;
}
const latestLinks = latestLinkObservations(links);
const nodePositions = new Map(nodes.map((node, index) => [node.id, topologyPoint(500, index, nodes.length, 125, 500)] as const));
const entryPositions = new Map(entryPoints.map((entryPoint, index) => [entryPoint.id, topologyPoint(160, index, entryPoints.length, 145, 480)] as const));
const egressPositions = new Map(egressPools.map((pool, index) => [pool.id, topologyPoint(840, index, egressPools.length, 145, 480)] as const));
const activeEntryAssignments = entryPoints.flatMap((entryPoint) =>
(entryPointNodesById[entryPoint.id] || [])
.filter((assignment) => assignment.status !== "disabled")
.map((assignment) => ({ entryPoint, assignment })),
);
const activeEgressAssignments = egressPools.flatMap((pool) =>
(egressPoolNodesById[pool.id] || [])
.filter((assignment) => assignment.status !== "disabled")
.map((assignment) => ({ pool, assignment })),
);
const placementCount = activeEntryAssignments.length + activeEgressAssignments.length;
return (
<div className="topologyShell">
<svg className="topologySvg" viewBox="0 0 1000 640" role="img" aria-label="Карта трафика узлов Fabric">
<defs>
<marker id="arrow" markerHeight="8" markerWidth="8" orient="auto" refX="7" refY="4">
<path d="M0,0 L8,4 L0,8 Z" fill="currentColor" />
</marker>
</defs>
<rect x="36" y="58" width="248" height="522" rx="30" className="topologyZone ingress" />
<rect x="322" y="58" width="356" height="522" rx="30" className="topologyZone core" />
<rect x="716" y="58" width="248" height="522" rx="30" className="topologyZone egress" />
<text x="160" y="98" className="topologyLayerLabel">
{labels.fabricIngressLayer}
</text>
<text x="500" y="98" className="topologyLayerLabel">
{labels.fabricNodeLayer}
</text>
<text x="840" y="98" className="topologyLayerLabel">
{labels.fabricEgressLayer}
</text>
{activeEntryAssignments.map(({ entryPoint, assignment }) => {
const source = entryPositions.get(entryPoint.id);
const target = nodePositions.get(assignment.node_id);
if (!source || !target) {
return null;
}
return (
<line
key={`entry-${entryPoint.id}-${assignment.node_id}`}
x1={source.x + 78}
y1={source.y}
x2={target.x - 62}
y2={target.y}
className={`topologyPlacementLink ${assignment.status === "active" ? "good" : "weak"}`}
markerEnd="url(#arrow)"
/>
);
})}
{activeEgressAssignments.map(({ pool, assignment }) => {
const source = nodePositions.get(assignment.node_id);
const target = egressPositions.get(pool.id);
if (!source || !target) {
return null;
}
return (
<line
key={`egress-${pool.id}-${assignment.node_id}`}
x1={source.x + 62}
y1={source.y}
x2={target.x - 78}
y2={target.y}
className={`topologyPlacementLink ${assignment.status === "active" ? "good" : "weak"}`}
markerEnd="url(#arrow)"
/>
);
})}
{latestLinks.map((link) => {
const source = nodePositions.get(link.source_node_id);
const target = nodePositions.get(link.target_node_id);
if (!source || !target) {
return null;
}
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
return (
<g key={link.id || `${link.source_node_id}-${link.target_node_id}`}>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
className={`topologyLink ${linkTone(link)}`}
markerEnd="url(#arrow)"
/>
<text x={midX} y={midY - 8} className="topologyLinkLabel">
{meshLinkTopologyLabel(link)}
</text>
</g>
);
})}
{entryPoints.map((entryPoint) => {
const point = entryPositions.get(entryPoint.id)!;
const count = (entryPointNodesById[entryPoint.id] || []).length;
return (
<g key={entryPoint.id} className="topologyEndpoint">
<rect x={point.x - 86} y={point.y - 36} width="172" height="72" rx="20" className={`topologyEndpointRect ${entryPoint.status}`} />
<text x={point.x} y={point.y - 8} className="topologyEndpointName">
{trimNodeName(entryPoint.name)}
</text>
<text x={point.x} y={point.y + 15} className="topologyEndpointMeta">
{entryPoint.endpoint_type} · {count}
</text>
</g>
);
})}
{nodes.map((node) => {
const point = nodePositions.get(node.id)!;
const latestTelemetry = (telemetryByNode[node.id] || [])[0];
return (
<g key={node.id} className="topologyNode">
<circle cx={point.x} cy={point.y} r="54" className={`topologyNodeCircle ${node.health_status}`} />
<text x={point.x} y={point.y - 12} className="topologyNodeName">
{trimNodeName(node.name)}
</text>
<text x={point.x} y={point.y + 10} className="topologyNodeMeta">
{(rolesByNode[node.id] || []).length} ролей / {(workloadsByNode[node.id] || []).length} серв.
</text>
<text x={point.x} y={point.y + 31} className="topologyNodeMeta">
{formatBytes(latestTelemetry?.memory_used_bytes)}
</text>
</g>
);
})}
{egressPools.map((pool) => {
const point = egressPositions.get(pool.id)!;
const count = (egressPoolNodesById[pool.id] || []).length;
return (
<g key={pool.id} className="topologyEndpoint">
<rect x={point.x - 86} y={point.y - 36} width="172" height="72" rx="20" className={`topologyEndpointRect ${pool.status}`} />
<text x={point.x} y={point.y - 8} className="topologyEndpointName">
{trimNodeName(pool.name)}
</text>
<text x={point.x} y={point.y + 15} className="topologyEndpointMeta">
egress · {count}
</text>
</g>
);
})}
{latestLinks.length === 0 && placementCount === 0 && (
<text x="500" y="606" className="topologyEmpty">
{emptyText}
</text>
)}
</svg>
<div className="topologyLegend">
<span>
<i className="legendLine placement" /> {labels.placementIntent}: {placementCount}
</span>
<span>
<i className="legendLine observed" /> {labels.observedPeerLinks}: {latestLinks.length}
</span>
</div>
<div className="serviceTags">
{entryPoints.map((entryPoint) => (
<div className="serviceTag" key={entryPoint.id}>
<strong>{entryPoint.name}</strong>
<span>{entryPoint.endpoint_type}</span>
<small>{(entryPointNodesById[entryPoint.id] || []).map((item) => nodeName(nodes, item.node_id)).join(", ") || labels.assignedNodesEmpty}</small>
</div>
))}
{nodes.map((node) => (
<div className="serviceTag" key={node.id}>
<strong>{node.name}</strong>
<span>{node.health_status}</span>
<small>{summarizeWorkloads(workloadsByNode[node.id] || [])}</small>
</div>
))}
{egressPools.map((pool) => (
<div className="serviceTag" key={pool.id}>
<strong>{pool.name}</strong>
<span>{pool.status}</span>
<small>{(egressPoolNodesById[pool.id] || []).map((item) => nodeName(nodes, item.node_id)).join(", ") || labels.assignedNodesEmpty}</small>
</div>
))}
</div>
</div>
);
}
function topologyPoint(x: number, index: number, count: number, top: number, bottom: number) {
if (count <= 1) {
return { x, y: Math.round((top + bottom) / 2) };
}
return { x, y: Math.round(top + ((bottom - top) * index) / (count - 1)) };
}
function NodeGroupTree({ groups, nodes, labels }: { groups: ClusterNodeGroup[]; nodes: ClusterNode[]; labels: Record<string, string> }) {
const rootGroups = groups
.filter((group) => !group.parent_group_id)
.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
const ungroupedNodes = nodes.filter((node) => !node.node_group_id);
if (groups.length === 0 && nodes.length === 0) {
return <EmptyState title="Нет узлов" text="Одобренные node-agent появятся здесь." />;
}
return (
<div className="treeList">
{rootGroups.map((group) => (
<NodeGroupBranch key={group.id} group={group} groups={groups} nodes={nodes} depth={0} />
))}
{ungroupedNodes.length > 0 && (
<section className="treeBranch">
<h4>{labels.ungroupedNodes}</h4>
<div className="treeNodeList">
{ungroupedNodes.map((node) => (
<span className="pill" key={node.id}>
{node.name}
</span>
))}
</div>
</section>
)}
</div>
);
}
function NodeGroupBranch({ group, groups, nodes, depth }: { group: ClusterNodeGroup; groups: ClusterNodeGroup[]; nodes: ClusterNode[]; depth: number }) {
const childGroups = groups
.filter((item) => item.parent_group_id === group.id)
.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
const groupedNodes = nodes.filter((node) => node.node_group_id === group.id);
return (
<section className="treeBranch" style={{ marginLeft: `${depth * 18}px` }}>
<h4>
{group.name}
<span className="pill">{groupedNodes.length}</span>
</h4>
{group.description && <p className="muted">{group.description}</p>}
<div className="treeNodeList">
{groupedNodes.map((node) => (
<span className="pill good" key={node.id}>
{node.name}
</span>
))}
</div>
{childGroups.map((child) => (
<NodeGroupBranch key={child.id} group={child} groups={groups} nodes={nodes} depth={depth + 1} />
))}
</section>
);
}
function FormGrid({ children }: { children: ReactNode }) {
return <div className="formGrid">{children}</div>;
}
function DataTable({ columns, rows }: { columns: string[]; rows: ReactNode[][] }) {
if (rows.length === 0) {
return <EmptyState title="Нет данных" text="В текущей области пока нечего показать." />;
}
return (
<div className="tableWrap">
<table>
<thead>
<tr>
{columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{row.map((cell, cellIndex) => (
<td key={`${index}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function parseJSONObject(raw: string, label: string): Record<string, unknown> {
const parsed = JSON.parse(raw || "{}") as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${label}: требуется JSON object.`);
}
return parsed as Record<string, unknown>;
}
function parseJSONArray(raw: string, label: string): unknown[] {
const parsed = JSON.parse(raw || "[]") as unknown;
if (!Array.isArray(parsed)) {
throw new Error(`${label}: требуется JSON array.`);
}
return parsed;
}
function summarizeRoles(roles: RoleAssignment[]) {
if (roles.length === 0) {
return "активные роли не назначены";
}
return roles.map((role) => `${role.role}${role.organization_id ? ` @ ${shortId(role.organization_id)}` : ""}`).join(", ");
}
function summarizeWorkloads(workloads: WorkloadStatus[]) {
if (workloads.length === 0) {
return "нет сервисов";
}
return workloads.map((workload) => `${workload.service_type}:${workload.reported_state}`).join(", ");
}
function latestLinkObservations(links: MeshLink[]) {
const byPair = new Map<string, MeshLink>();
for (const link of links) {
const key = `${link.source_node_id}->${link.target_node_id}:${meshLinkObservationKey(link)}`;
const existing = byPair.get(key);
if (!existing || new Date(link.observed_at).getTime() > new Date(existing.observed_at).getTime()) {
byPair.set(key, link);
}
}
return [...byPair.values()];
}
function meshLinkObservationKey(link: MeshLink) {
const observationType = meshLinkMetadataString(link, "observation_type") || "default";
if (observationType === "synthetic_route_health") {
return `${observationType}:${meshLinkMetadataString(link, "route_id") || link.id}`;
}
if (observationType === "peer_connection_manager") {
return `${observationType}:${meshLinkMetadataString(link, "transport_mode")}:${meshLinkMetadataString(link, "relay_node_id")}`;
}
return observationType;
}
function meshLinkObservationLabel(link: MeshLink) {
const observationType = meshLinkMetadataString(link, "observation_type");
if (observationType === "synthetic_route_health") {
const drift = link.metadata?.route_path_drift_detected === true ? "drift" : "ok";
const applied = link.metadata?.route_path_decision_applied === true ? "decision" : "route";
return `route-health ${applied} ${drift}`;
}
if (observationType === "peer_connection_manager") {
const mode = meshLinkMetadataString(link, "transport_mode") || "manager";
const state = meshLinkMetadataString(link, "connection_state");
return state ? `${mode} ${state}` : mode;
}
return observationType || "link";
}
function meshLinkRoutePathSummary(link: MeshLink, nodes: ClusterNode[]) {
const routeID = meshLinkMetadataString(link, "route_id");
const relayID = meshLinkMetadataString(link, "route_path_decision_selected_relay_id") || meshLinkMetadataString(link, "relay_node_id");
const hops = meshLinkMetadataStringArray(link, "expected_effective_hops");
const observed = meshLinkMetadataStringArray(link, "observed_ack_path");
const path = hops.length > 0 ? hops : observed;
const parts: string[] = [];
if (routeID) {
parts.push(shortId(routeID));
}
if (relayID) {
parts.push(`via ${shortId(relayID)}`);
}
if (path.length > 0) {
parts.push(path.map((id) => trimNodeName(nodeName(nodes, id))).join(" > "));
}
return parts.length > 0 ? parts.join(" / ") : "н/д";
}
function meshLinkTopologyLabel(link: MeshLink) {
if (meshLinkMetadataString(link, "observation_type") === "synthetic_route_health") {
return link.metadata?.route_path_drift_detected === true ? "drift" : "route";
}
return link.latency_ms == null ? "связь" : `${link.latency_ms}мс`;
}
function meshLinkMetadataString(link: MeshLink, key: string) {
const value = link.metadata?.[key];
return typeof value === "string" ? value : "";
}
function meshLinkMetadataStringArray(link: MeshLink, key: string) {
const value = link.metadata?.[key];
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function countPeerEndpointCandidates(config: NodeSyntheticMeshConfig) {
return Object.values(config.peer_endpoint_candidates || {}).reduce((sum, candidates) => sum + candidates.length, 0);
}
function rendezvousRelayPolicySummary(config?: NodeSyntheticMeshConfig) {
const policy = config?.rendezvous_relay_policy;
if (!policy) {
return "none";
}
const parts = [`stale${policy.stale_relay_count}`, `wd${policy.withdrawn_lease_count}`, `repl${policy.replacement_lease_count}`];
if (policy.scoring_mode.includes("synthetic_route_health_feedback")) {
parts.push("rh feedback");
}
const selected = policy.decisions?.find((decision) => decision.selected_relay_id);
if (selected?.selected_relay_id) {
parts.push(`via ${shortId(selected.selected_relay_id)}`);
}
return parts.join(" ");
}
function routePathDecisionSummary(config?: NodeSyntheticMeshConfig) {
const report = config?.route_path_decisions;
if (!report) {
return "none";
}
const parts = [`path${report.decision_count}`, `repl${report.replacement_decision_count}`];
const selected = report.decisions?.find((decision) => decision.selected_relay_id || decision.next_hop_id);
if (selected?.selected_relay_id) {
parts.push(`via ${shortId(selected.selected_relay_id)}`);
} else if (selected?.next_hop_id) {
parts.push(`next ${shortId(selected.next_hop_id)}`);
}
return parts.join(" ");
}
function nodeName(nodes: ClusterNode[], id: string) {
return nodes.find((node) => node.id === id)?.name || shortId(id);
}
function groupPathLabel(group: ClusterNodeGroup, groups: ClusterNodeGroup[]) {
const byId = new Map(groups.map((item) => [item.id, item]));
const parts = [group.name];
let parentID = group.parent_group_id;
const seen = new Set<string>([group.id]);
while (parentID && !seen.has(parentID)) {
seen.add(parentID);
const parent = byId.get(parentID);
if (!parent) {
break;
}
parts.unshift(parent.name);
parentID = parent.parent_group_id;
}
return parts.join(" / ");
}
function groupPathLabelById(groupId: string, groups: ClusterNodeGroup[]) {
const group = groups.find((item) => item.id === groupId);
return group ? groupPathLabel(group, groups) : groupId;
}
function descendantGroupIds(groupId: string, groups: ClusterNodeGroup[]) {
const result: string[] = [];
const childrenByParent = new Map<string, ClusterNodeGroup[]>();
for (const group of groups) {
const parentKey = group.parent_group_id || "";
childrenByParent.set(parentKey, [...(childrenByParent.get(parentKey) || []), group]);
}
const visit = (parentId: string) => {
for (const child of childrenByParent.get(parentId) || []) {
result.push(child.id);
visit(child.id);
}
};
visit(groupId);
return result;
}
function buildNodeInventoryTreeRows(
entries: NodeInventoryEntry[],
groups: ClusterNodeGroup[],
selectedClusterId: string,
labels: Record<string, string>,
collapsedGroupKeys: Set<string>,
): NodeInventoryTreeRow[] {
const rows: NodeInventoryTreeRow[] = [];
const entriesByGroup = new Map<string, NodeInventoryEntry[]>();
const activeUngrouped: NodeInventoryEntry[] = [];
const outsideActiveCluster: NodeInventoryEntry[] = [];
for (const entry of entries) {
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
if (!activeMembership) {
outsideActiveCluster.push(entry);
continue;
}
const groupID = activeMembership.node.node_group_id;
if (!groupID) {
activeUngrouped.push(entry);
continue;
}
entriesByGroup.set(groupID, [...(entriesByGroup.get(groupID) || []), entry]);
}
const childrenByParent = new Map<string, ClusterNodeGroup[]>();
for (const group of groups) {
const parentKey = group.parent_group_id || "";
childrenByParent.set(parentKey, [...(childrenByParent.get(parentKey) || []), group]);
}
for (const childGroups of childrenByParent.values()) {
childGroups.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
}
const countByGroup = new Map<string, number>();
const countGroupEntries = (group: ClusterNodeGroup): number => {
const cached = countByGroup.get(group.id);
if (cached != null) {
return cached;
}
let count = entriesByGroup.get(group.id)?.length || 0;
for (const child of childrenByParent.get(group.id) || []) {
count += countGroupEntries(child);
}
countByGroup.set(group.id, count);
return count;
};
const appendGroup = (group: ClusterNodeGroup, depth: number): number => {
const ownEntries = [...(entriesByGroup.get(group.id) || [])].sort((left, right) => left.node.name.localeCompare(right.node.name));
const childGroups = childrenByParent.get(group.id) || [];
const rowKey = `group-${group.id}`;
const count = countGroupEntries(group);
rows.push({ kind: "group", key: rowKey, label: group.name, depth, count, groupId: group.id });
if (!collapsedGroupKeys.has(rowKey)) {
for (const entry of ownEntries) {
rows.push({ kind: "node", key: `node-${group.id}-${entry.node.id}`, entry, depth: depth + 1 });
}
for (const child of childGroups) {
appendGroup(child, depth + 1);
}
}
return count;
};
for (const group of childrenByParent.get("") || []) {
appendGroup(group, 0);
}
if (activeUngrouped.length > 0) {
const rowKey = "group-ungrouped";
rows.push({ kind: "group", key: rowKey, label: labels.ungroupedNodes, depth: 0, count: activeUngrouped.length });
if (!collapsedGroupKeys.has(rowKey)) {
for (const entry of activeUngrouped.sort((left, right) => left.node.name.localeCompare(right.node.name))) {
rows.push({ kind: "node", key: `node-ungrouped-${entry.node.id}`, entry, depth: 1 });
}
}
}
if (outsideActiveCluster.length > 0) {
const rowKey = "group-outside-active-cluster";
rows.push({
kind: "group",
key: rowKey,
label: labels.notMemberOfActiveCluster,
depth: 0,
count: outsideActiveCluster.length,
});
if (!collapsedGroupKeys.has(rowKey)) {
for (const entry of outsideActiveCluster.sort((left, right) => left.node.name.localeCompare(right.node.name))) {
rows.push({ kind: "node", key: `node-outside-${entry.node.id}`, entry, depth: 1 });
}
}
}
return rows;
}
function nodesWithRole(role: string, rolesByNode: Record<string, RoleAssignment[]>) {
return Object.entries(rolesByNode)
.filter(([, roles]) => roles.some((assignment) => assignment.role === role && assignment.status === "active"))
.map(([nodeId]) => nodeId);
}
function buildJoinTokenScope(input: { roles: string[]; ownershipType: string; purpose: string }): Record<string, unknown> {
return {
roles: input.roles,
ownership_type: input.ownershipType,
purpose: input.purpose.trim() || null,
approval: {
mode: "manual",
auto_approve: false,
role_assignment: "manual_after_approval",
},
source: "platform_owner_console",
};
}
function toggleArrayValue(values: string[], value: string) {
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
}
function groupNodeInventory(
entries: NodeInventoryEntry[],
selectedClusterId: string,
search: string,
groupBy: "membership" | "health" | "ownership" | "cluster_count",
language: Language,
) {
const normalizedSearch = search.trim().toLowerCase();
const groups = new Map<string, NodeInventoryEntry[]>();
for (const entry of entries) {
if (normalizedSearch && !nodeInventoryMatches(entry, normalizedSearch)) {
continue;
}
const key = nodeInventoryGroupLabel(entry, selectedClusterId, groupBy, language);
groups.set(key, [...(groups.get(key) || []), entry]);
}
return Array.from(groups.entries())
.map(([label, items]) => ({ label, items: items.sort((left, right) => left.node.name.localeCompare(right.node.name)) }))
.sort((left, right) => left.label.localeCompare(right.label));
}
function nodeInventoryMatches(entry: NodeInventoryEntry, search: string) {
const values = [
entry.node.name,
entry.node.node_key,
entry.node.health_status,
entry.node.ownership_type,
entry.node.reported_version || "",
...entry.memberships.flatMap((membership) => [membership.cluster.name, membership.cluster.slug, membership.node.membership_status]),
];
return values.some((value) => value.toLowerCase().includes(search));
}
function nodeInventoryGroupLabel(
entry: NodeInventoryEntry,
selectedClusterId: string,
groupBy: "membership" | "health" | "ownership" | "cluster_count",
language: Language,
) {
if (groupBy === "health") {
return statusLabel(entry.node.health_status);
}
if (groupBy === "ownership") {
return statusLabel(entry.node.ownership_type);
}
if (groupBy === "cluster_count") {
return clusterCountLabel(entry.memberships.length, language);
}
const activeMembership = entry.memberships.find((membership) => membership.cluster.id === selectedClusterId);
if (!activeMembership) {
return language === "en" ? "Not in active cluster" : "Не в активном кластере";
}
if (activeMembership.node.membership_status === "active") {
return language === "en" ? "In active cluster" : "В активном кластере";
}
return `${language === "en" ? "Membership" : "Участие"}: ${statusLabel(activeMembership.node.membership_status)}`;
}
function capabilityState(role: string, heartbeat?: NodeHeartbeat) {
const expectedKeys = capabilityKeysByRole[role] || [];
if (expectedKeys.length === 0 || !heartbeat) {
return "unknown";
}
const capabilities = heartbeat.capabilities || {};
return expectedKeys.some((key) => Boolean(capabilities[key])) ? "confirmed" : "missing";
}
function capabilityPillTone(role: string, heartbeat?: NodeHeartbeat) {
const state = capabilityState(role, heartbeat);
if (state === "confirmed") {
return "good";
}
if (state === "missing") {
return "bad";
}
return "";
}
function capabilityLabel(role: string, heartbeat: NodeHeartbeat | undefined, labels: Record<string, string>) {
const state = capabilityState(role, heartbeat);
if (state === "confirmed") {
return labels.capabilityConfirmed;
}
if (state === "missing") {
return labels.capabilityMissing;
}
return labels.capabilityUnknown;
}
function capabilityOptionLabel(role: string, heartbeat: NodeHeartbeat | undefined, language: Language) {
const state = capabilityState(role, heartbeat);
if (language === "en") {
return state === "confirmed" ? "capable" : state === "missing" ? "not reported" : "unknown";
}
return state === "confirmed" ? "подходит" : state === "missing" ? "не заявлено" : "неизвестно";
}
function meshRecoverySummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_peer_recovery_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return "н/д";
}
const fields = report as Record<string, unknown>;
const mode = typeof fields.mode === "string" ? fields.mode : "unknown";
const ready = typeof fields.ready_peer_count === "number" ? fields.ready_peer_count : null;
const target = typeof fields.target_ready_peers === "number" ? fields.target_ready_peers : null;
const deficit = typeof fields.deficit === "number" ? fields.deficit : 0;
const base = ready == null || target == null ? mode : `${mode} ${ready}/${target}`;
return deficit > 0 ? `${base} deficit ${deficit}` : base;
}
function meshConnectionIntentSummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_peer_connection_intent_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return meshConnectionManagerSummary(heartbeat);
}
const fields = report as Record<string, unknown>;
const intentCount = typeof fields.intent_count === "number" ? fields.intent_count : 0;
const maintain = typeof fields.maintain_count === "number" ? fields.maintain_count : 0;
const recover = typeof fields.recover_count === "number" ? fields.recover_count : 0;
const rendezvous = typeof fields.rendezvous_required_count === "number" ? fields.rendezvous_required_count : 0;
const resolved = typeof fields.rendezvous_resolved_count === "number" ? fields.rendezvous_resolved_count : 0;
const relayControl = typeof fields.relay_control_count === "number" ? fields.relay_control_count : 0;
const rvPart = [`rv${rendezvous}`];
if (resolved > 0) {
rvPart.push(`ok${resolved}`);
}
if (relayControl > 0) {
rvPart.push(`relay${relayControl}`);
}
const base =
rendezvous > 0 || resolved > 0 || relayControl > 0
? `${intentCount} intents m${maintain}/r${recover} ${rvPart.join("/")}`
: `${intentCount} intents m${maintain}/r${recover}`;
const manager = meshConnectionManagerSummary(heartbeat);
return manager === "н/д" ? base : `${base}; ${manager}`;
}
function meshRendezvousLeaseSummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_rendezvous_lease_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return "н/д";
}
const fields = report as Record<string, unknown>;
const total = typeof fields.lease_count === "number" ? fields.lease_count : 0;
const active = typeof fields.active_count === "number" ? fields.active_count : 0;
const relay = typeof fields.admitted_as_relay_count === "number" ? fields.admitted_as_relay_count : 0;
const peer = typeof fields.admitted_as_peer_count === "number" ? fields.admitted_as_peer_count : 0;
const renewal = typeof fields.renewal_needed_count === "number" ? fields.renewal_needed_count : 0;
const ready = typeof fields.relay_control_ready_count === "number" ? fields.relay_control_ready_count : 0;
const stale = typeof fields.stale_relay_count === "number" ? fields.stale_relay_count : 0;
const refreshAttempts = typeof fields.refresh_attempt_count === "number" ? fields.refresh_attempt_count : 0;
const refreshSuccess = typeof fields.refresh_success_count === "number" ? fields.refresh_success_count : 0;
const parts = [`lease ${active}/${total}`];
if (relay > 0) {
parts.push(`relay${relay}`);
}
if (peer > 0) {
parts.push(`peer${peer}`);
}
if (renewal > 0) {
parts.push(`renew${renewal}`);
}
if (stale > 0) {
parts.push(`stale${stale}`);
}
if (ready > 0) {
parts.push(`ready${ready}`);
}
if (refreshAttempts > 0) {
parts.push(`ref${refreshSuccess}/${refreshAttempts}`);
}
return parts.join(" ");
}
function meshRoutePathDecisionSummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_route_path_decision_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return "н/д";
}
const fields = report as Record<string, unknown>;
const total = typeof fields.decision_count === "number" ? fields.decision_count : 0;
const replacements = typeof fields.replacement_decision_count === "number" ? fields.replacement_decision_count : 0;
const local = typeof fields.local_effective_path_count === "number" ? fields.local_effective_path_count : 0;
const next = typeof fields.next_hop_available_count === "number" ? fields.next_hop_available_count : 0;
const withdrawn = typeof fields.withdrawn_local_relay_count === "number" ? fields.withdrawn_local_relay_count : 0;
const parts = [`path ${local}/${total}`];
if (replacements > 0) {
parts.push(`repl${replacements}`);
}
if (next > 0) {
parts.push(`next${next}`);
}
if (withdrawn > 0) {
parts.push(`wd${withdrawn}`);
}
return parts.join(" ");
}
function meshRouteGenerationSummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_route_generation_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return "н/д";
}
const fields = report as Record<string, unknown>;
const active = typeof fields.active_decision_count === "number" ? fields.active_decision_count : 0;
const applied = typeof fields.applied_decision_count === "number" ? fields.applied_decision_count : 0;
const withdrawn = typeof fields.withdrawn_decision_count === "number" ? fields.withdrawn_decision_count : 0;
const changed = fields.generation_changed === true;
const parts = [`gen ${active}`];
if (applied > 0) {
parts.push(`ap${applied}`);
}
if (withdrawn > 0) {
parts.push(`wd${withdrawn}`);
}
if (changed) {
parts.push("chg");
}
return parts.join(" ");
}
function meshRouteHealthConfigSummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_route_health_config_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return "н/д";
}
const fields = report as Record<string, unknown>;
const feedbackReport = heartbeat?.metadata?.mesh_route_health_feedback_refresh_report;
const feedbackFields =
feedbackReport && typeof feedbackReport === "object" && !Array.isArray(feedbackReport)
? (feedbackReport as Record<string, unknown>)
: {};
const routes = typeof fields.route_health_route_count === "number" ? fields.route_health_route_count : 0;
const applied = typeof fields.route_path_decision_applied_count === "number" ? fields.route_path_decision_applied_count : 0;
const replacements = typeof fields.replacement_route_health_route_count === "number" ? fields.replacement_route_health_route_count : 0;
const drift = typeof fields.route_health_decision_drift_candidate_count === "number" ? fields.route_health_decision_drift_candidate_count : 0;
const feedbackAttempts =
typeof feedbackFields.feedback_refresh_attempt_count === "number"
? feedbackFields.feedback_refresh_attempt_count
: typeof fields.feedback_refresh_attempt_count === "number"
? fields.feedback_refresh_attempt_count
: 0;
const feedbackSuccesses =
typeof feedbackFields.feedback_refresh_success_count === "number"
? feedbackFields.feedback_refresh_success_count
: typeof fields.feedback_refresh_success_count === "number"
? fields.feedback_refresh_success_count
: 0;
const feedbackSuppressed =
typeof feedbackFields.feedback_refresh_suppressed_count === "number"
? feedbackFields.feedback_refresh_suppressed_count
: typeof fields.feedback_refresh_suppressed_count === "number"
? fields.feedback_refresh_suppressed_count
: 0;
const parts = [`rh ${applied}/${routes}`];
if (replacements > 0) {
parts.push(`repl${replacements}`);
}
if (drift > 0) {
parts.push(`drift${drift}`);
}
if (feedbackAttempts > 0 || feedbackSuppressed > 0) {
parts.push(`fb${feedbackSuccesses}/${feedbackAttempts}`);
}
if (feedbackSuppressed > 0) {
parts.push(`sup${feedbackSuppressed}`);
}
return parts.join(" ");
}
function meshConnectionManagerSummary(heartbeat?: NodeHeartbeat) {
const report = heartbeat?.metadata?.mesh_peer_connection_manager_report;
if (!report || typeof report !== "object" || Array.isArray(report)) {
return "н/д";
}
const fields = report as Record<string, unknown>;
if (fields.enabled === false) {
return "manager off";
}
const attempted = typeof fields.attempted === "number" ? fields.attempted : 0;
const succeeded = typeof fields.succeeded === "number" ? fields.succeeded : 0;
const deferred = typeof fields.deferred === "number" ? fields.deferred : 0;
const relayControl = typeof fields.relay_control_count === "number" ? fields.relay_control_count : 0;
const base = relayControl > 0 ? `mgr ${succeeded}/${attempted} relay${relayControl}` : `mgr ${succeeded}/${attempted}`;
return deferred > 0 ? `${base} def${deferred}` : base;
}
function clusterCountLabel(count: number, language: Language) {
if (language === "en") {
return count === 1 ? "1 cluster" : `${count} clusters`;
}
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) {
return `${count} кластер`;
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} кластера`;
}
return `${count} кластеров`;
}
function linkTone(link: MeshLink) {
if (link.link_status !== "reachable") {
return "bad";
}
if (link.quality_score != null && link.quality_score < 70) {
return "weak";
}
if (link.latency_ms != null && link.latency_ms > 80) {
return "weak";
}
return "good";
}
function trimNodeName(value: string) {
return value.length > 16 ? `${value.slice(0, 14)}` : value;
}
function confirmHighRisk(action: string) {
return window.confirm(`${action}?\n\nЭто высокорисковая операция владельца платформы. Действие будет записано в аудит.`);
}
function shortId(value?: string | null) {
if (!value) {
return "нет";
}
return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value;
}
function formatDate(value?: string | null) {
if (!value) {
return "никогда";
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatBytes(value?: number | null) {
if (value == null || Number.isNaN(value)) {
return "н/д";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function statusLabel(value: string) {
const labels: Record<string, string> = {
active: "активно",
approved: "одобрено",
authoritative: "authoritative",
connecting: "подключается",
critical: "критично",
degraded: "degraded",
disabled: "выключено",
enabled: "включено",
failed: "ошибка",
healthy: "здоров",
isolated: "изолирован",
pending: "ожидает",
platform_managed: "платформенный",
rejected: "отклонено",
revoked: "отозвано",
running: "работает",
customer_managed: "клиентский",
stopped: "остановлено",
unknown: "неизвестно",
};
return labels[value] || value;
}