@@ -7189,7 +7656,7 @@ function NodeDetailsDashboard({
@@ -7216,7 +7683,7 @@ function NodeDetailsDashboard({
-
+
@@ -8564,6 +9031,229 @@ function numberField(source: Record | undefined, key: string, f
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
+function staleNodeRecoveryRecommendation(node: StaleNodeRiskReport["nodes"][number]) {
+ if (node.direct_peer_alert) {
+ return `У узла только ${node.direct_peer_ready_count || 0} из ${node.direct_peer_target_count || 3} готовых прямых QUIC-связей: добавить/починить advertise endpoints, NAT-forwarding или соседние relay-capable peers до минимума в 3 ready direct peers.`;
+ }
+ const missingArtifacts = node.products.filter((product) => !product.compatible_artifact_found).map((product) => product.product);
+ if (missingArtifacts.length > 0) {
+ return `Добавить recovery artifacts для ${missingArtifacts.join(", ")} и не снимать compatibility overlap.`;
+ }
+ const legacyRecoveryContracts = node.risks
+ .filter((risk) => risk.startsWith("stale_node_legacy_recovery_contract_"))
+ .map((risk) => risk.replace("stale_node_legacy_recovery_contract_", ""));
+ if (legacyRecoveryContracts.length > 0) {
+ if (node.recovery_bridge_replay_ready) {
+ return `Узел застрял на старом recovery-контракте (${legacyRecoveryContracts.join(", ")}): bridge replay уже готов, держать recovery bridge / compatibility aliases / overlap и ждать следующий recovery-цикл узла.`;
+ }
+ return `Узел застрял на старом recovery-контракте (${legacyRecoveryContracts.join(", ")}): держать recovery bridge / compatibility aliases / overlap и не удалять старые recovery-форматы до возврата heartbeat.`;
+ }
+ const unknownProfiles = node.risks
+ .filter((risk) => risk.startsWith("stale_node_unknown_profile_"))
+ .map((risk) => risk.replace("stale_node_unknown_profile_", ""));
+ if (unknownProfiles.length > 0) {
+ return `Неизвестен update profile (${unknownProfiles.join(", ")}): нужен heartbeat/update-status или fallback-profile alias.`;
+ }
+ const unknownVersions = node.risks
+ .filter((risk) => risk.startsWith("stale_node_unknown_") && risk.endsWith("_version"))
+ .map((risk) => risk.replace("stale_node_unknown_", "").replace("_version", ""));
+ if (unknownVersions.length > 0) {
+ return `Нет подтвержденной версии (${unknownVersions.join(", ")}): сохранить overlap и проверить last known good release mapping.`;
+ }
+ const missingStatuses = node.risks
+ .filter((risk) => risk.startsWith("stale_node_no_") && risk.endsWith("_update_status"))
+ .map((risk) => risk.replace("stale_node_no_", "").replace("_update_status", ""));
+ if (missingStatuses.length > 0) {
+ return `Узел молчит и не прислал update-status (${missingStatuses.join(", ")}): держать compatibility и чинить recovery channel.`;
+ }
+ if (node.risks.includes("stale_heartbeat")) {
+ return "Узел stale, но recovery artifacts уже есть: не удалять совместимость, ждать исходящий recovery heartbeat и проверить bootstrap/registry gossip.";
+ }
+ return "Риск под контролем: можно наблюдать heartbeat и готовить controlled cleanup после отдельной проверки.";
+}
+
+function staleNodeRecoveryReadiness(node: StaleNodeRiskReport["nodes"][number]) {
+ if (node.direct_peer_alert) {
+ return `direct peer alert: ${node.direct_peer_ready_count || 0}/${node.direct_peer_target_count || 3} ready`;
+ }
+ const missingArtifacts = node.products.filter((product) => !product.compatible_artifact_found).map((product) => product.product);
+ if (missingArtifacts.length > 0) {
+ return `artifact gap: ${missingArtifacts.join(", ")}`;
+ }
+ const legacyRecoveryContracts = node.risks
+ .filter((risk) => risk.startsWith("stale_node_legacy_recovery_contract_"))
+ .map((risk) => risk.replace("stale_node_legacy_recovery_contract_", ""));
+ if (legacyRecoveryContracts.length > 0) {
+ if (node.recovery_bridge_replay_ready) {
+ return `bridge replay ready: ${legacyRecoveryContracts.join(", ")}`;
+ }
+ return `recovery bridge required: ${legacyRecoveryContracts.join(", ")}`;
+ }
+ const unknownProfiles = node.risks
+ .filter((risk) => risk.startsWith("stale_node_unknown_profile_"))
+ .map((risk) => risk.replace("stale_node_unknown_profile_", ""));
+ if (unknownProfiles.length > 0) {
+ return `profile unknown: ${unknownProfiles.join(", ")}`;
+ }
+ const missingStatuses = node.risks
+ .filter((risk) => risk.startsWith("stale_node_no_") && risk.endsWith("_update_status"))
+ .map((risk) => risk.replace("stale_node_no_", "").replace("_update_status", ""));
+ if (missingStatuses.length > 0) {
+ return `waiting update status: ${missingStatuses.join(", ")}`;
+ }
+ const unknownVersions = node.risks
+ .filter((risk) => risk.startsWith("stale_node_unknown_") && risk.endsWith("_version"))
+ .map((risk) => risk.replace("stale_node_unknown_", "").replace("_version", ""));
+ if (unknownVersions.length > 0) {
+ return `version unknown: ${unknownVersions.join(", ")}`;
+ }
+ if (node.heartbeat_stale) {
+ return "artifacts ready, waiting recovery heartbeat";
+ }
+ return "recovery ready";
+}
+
+function auditEventMatchesNode(event: AuditEvent, nodeId: string) {
+ if (!nodeId) {
+ return true;
+ }
+ if (event.target_id === nodeId) {
+ return true;
+ }
+ const payload = objectField(event.payload);
+ const candidateKeys = [
+ "node_id",
+ "target_node_id",
+ "reporter_node_id",
+ "entry_node_id",
+ "exit_node_id",
+ "selected_node_id",
+ ];
+ return candidateKeys.some((key) => stringField(payload, key, "") === nodeId);
+}
+
+function meshListenerEndpointCandidatesToText(config: Record) {
+ const candidates = arrayObjects(config.endpoint_candidates);
+ if (candidates.length > 0) {
+ return candidates
+ .map((candidate) => {
+ const address = stringField(candidate, "address", "");
+ if (!address) {
+ return "";
+ }
+ const parts = [address];
+ for (const key of ["reachability", "connectivity_mode", "nat_type", "region", "priority"] as const) {
+ const value = stringField(candidate, key, "");
+ if (value) {
+ parts.push(`${key.replace("_mode", "").replace("_type", "")}=${value}`);
+ }
+ }
+ const metadata = objectField(candidate.metadata);
+ for (const key of ["provider", "interface", "maps_to"] as const) {
+ const value = stringField(metadata, key, "");
+ if (value) {
+ parts.push(`${key}=${value}`);
+ }
+ }
+ return parts.join(" ");
+ })
+ .filter(Boolean)
+ .join("\n");
+ }
+ const endpoints = Array.isArray(config.advertise_endpoints) ? config.advertise_endpoints : [];
+ if (endpoints.length > 0) {
+ return endpoints.map((endpoint) => String(endpoint || "").trim()).filter(Boolean).join("\n");
+ }
+ return stringField(config, "advertise_endpoint", "");
+}
+
+function parseMeshListenerEndpointCandidates(
+ draft: {
+ endpointCandidates: string;
+ advertiseEndpoint: string;
+ advertiseTransport: string;
+ connectivity: string;
+ nat: string;
+ region: string;
+ },
+ nodeID: string,
+) {
+ const lines = draft.endpointCandidates
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#"));
+ if (lines.length === 0 && draft.advertiseEndpoint.trim()) {
+ lines.push(draft.advertiseEndpoint.trim());
+ }
+ const seen = new Set();
+ return lines.flatMap((line, index) => {
+ const tokens = line.split(/\s+/).filter(Boolean);
+ const address = (tokens.shift() || "").replace(/\/$/, "");
+ if (!address || seen.has(address)) {
+ return [];
+ }
+ seen.add(address);
+ const options: Record = {};
+ for (const token of tokens) {
+ const splitAt = token.indexOf("=");
+ if (splitAt > 0) {
+ options[token.slice(0, splitAt).trim().toLowerCase()] = token.slice(splitAt + 1).trim();
+ } else if (["public", "private", "relay", "outbound_only", "unknown"].includes(token)) {
+ options.reachability = token;
+ } else if (["direct", "private_lan", "relay_required"].includes(token)) {
+ options.connectivity = token;
+ } else if (["none", "full_cone", "restricted", "port_restricted", "symmetric", "blocked"].includes(token)) {
+ options.nat = token;
+ }
+ }
+ const connectivity = options.connectivity || options.connectivity_mode || draft.connectivity || "direct";
+ const reachability = options.reachability || reachabilityFromEndpointInput(address, connectivity);
+ const nat = options.nat || options.nat_type || draft.nat || "unknown";
+ const metadata = Object.fromEntries(
+ Object.entries(options).filter(([key]) => !["reachability", "connectivity", "connectivity_mode", "nat", "nat_type", "priority", "region", "transport"].includes(key)),
+ );
+ return [
+ {
+ endpoint_id: `${nodeID}-operator-${index + 1}`,
+ node_id: nodeID,
+ address,
+ transport: options.transport || draft.advertiseTransport || "direct_quic",
+ reachability,
+ connectivity_mode: connectivity,
+ nat_type: nat,
+ region: options.region || draft.region || undefined,
+ priority: Number.isFinite(Number(options.priority)) ? Number(options.priority) : index + 1,
+ policy_tags: ["operator-configured", "desired-mesh-listener"],
+ metadata: {
+ source: "web-admin.mesh-listener",
+ verification_scope: reachability === "public" ? "external-network-required" : "local-or-peer-probe",
+ ...metadata,
+ },
+ },
+ ];
+ });
+}
+
+function reachabilityFromEndpointInput(address: string, connectivity: string) {
+ if (connectivity === "relay_required") {
+ return "relay";
+ }
+ if (connectivity === "outbound_only") {
+ return "outbound_only";
+ }
+ const host = address.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[/:?#]/)[0] || "";
+ if (
+ host === "localhost" ||
+ host.startsWith("127.") ||
+ host.startsWith("10.") ||
+ host.startsWith("192.168.") ||
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(host)
+ ) {
+ return "private";
+ }
+ return "public";
+}
+
function compactJSON(value: unknown) {
if (value == null) {
return "н/д";
@@ -8775,6 +9465,9 @@ function nodesWithRole(role: string, rolesByNode: Record {
+ const meshEndpointCandidates = parseJoinTokenEndpointCandidates(input);
+ const meshAdvertiseEndpoints = meshEndpointCandidates.map((candidate) => String(candidate.address || "")).filter(Boolean);
+ const primaryMeshAdvertiseEndpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "") || meshAdvertiseEndpoints[0] || "";
const scope: Record = {
roles: input.roles,
node_name: input.nodeName.trim() || null,
@@ -8805,16 +9498,19 @@ function buildJoinTokenScope(input: JoinTokenFormState): Record
scope.restart_policy = "unless-stopped";
scope.pull_image = Boolean(input.pullImage);
scope.replace = input.replace !== false;
- scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime !== false;
+ scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime === true;
scope.mesh_production_forwarding_enabled = false;
- scope.mesh_listen_addr = input.meshListenAddr || ":19131";
+ scope.mesh_listen_addr = input.meshListenAddr || "";
scope.mesh_listen_port_mode = input.meshListenPortMode || "auto";
scope.mesh_listen_auto_port_start = input.meshListenAutoPortStart || 19131;
scope.mesh_listen_auto_port_end = input.meshListenAutoPortEnd || 19231;
- if (input.meshAdvertiseEndpoint?.trim()) {
- scope.mesh_advertise_endpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "");
+ if (primaryMeshAdvertiseEndpoint) {
+ scope.mesh_advertise_endpoint = primaryMeshAdvertiseEndpoint;
}
- scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_http";
+ if (meshEndpointCandidates.length > 0) {
+ scope.mesh_advertise_endpoints_json = JSON.stringify(meshEndpointCandidates);
+ }
+ scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_quic";
scope.mesh_connectivity_mode = input.meshConnectivityMode || "private_lan";
scope.mesh_nat_type = input.meshNATType || "unknown";
scope.mesh_region = input.meshRegion || null;
@@ -8832,16 +9528,19 @@ function buildJoinTokenScope(input: JoinTokenFormState): Record
if (input.windowsNodeAgentSHA256.trim()) {
scope.node_agent_artifact_sha256 = input.windowsNodeAgentSHA256.trim();
}
- scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime !== false;
+ scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime === true;
scope.mesh_production_forwarding_enabled = false;
- scope.mesh_listen_addr = input.meshListenAddr || ":19131";
+ scope.mesh_listen_addr = input.meshListenAddr || "";
scope.mesh_listen_port_mode = input.meshListenPortMode || "auto";
scope.mesh_listen_auto_port_start = input.meshListenAutoPortStart || 19131;
scope.mesh_listen_auto_port_end = input.meshListenAutoPortEnd || 19231;
- if (input.meshAdvertiseEndpoint?.trim()) {
- scope.mesh_advertise_endpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "");
+ if (primaryMeshAdvertiseEndpoint) {
+ scope.mesh_advertise_endpoint = primaryMeshAdvertiseEndpoint;
}
- scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_http";
+ if (meshEndpointCandidates.length > 0) {
+ scope.mesh_advertise_endpoints_json = JSON.stringify(meshEndpointCandidates);
+ }
+ scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_quic";
scope.mesh_connectivity_mode = input.meshConnectivityMode || "outbound_only";
scope.mesh_nat_type = input.meshNATType || "unknown";
scope.mesh_region = input.meshRegion || "windows";
@@ -8860,16 +9559,19 @@ function buildJoinTokenScope(input: JoinTokenFormState): Record
scope.node_agent_artifact_sha256 = input.linuxNodeAgentSHA256.trim();
}
scope.replace = input.replace !== false;
- scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime !== false;
+ scope.mesh_synthetic_runtime_enabled = input.syntheticRuntime === true;
scope.mesh_production_forwarding_enabled = false;
- scope.mesh_listen_addr = input.meshListenAddr || ":19131";
+ scope.mesh_listen_addr = input.meshListenAddr || "";
scope.mesh_listen_port_mode = input.meshListenPortMode || "auto";
scope.mesh_listen_auto_port_start = input.meshListenAutoPortStart || 19131;
scope.mesh_listen_auto_port_end = input.meshListenAutoPortEnd || 19231;
- if (input.meshAdvertiseEndpoint?.trim()) {
- scope.mesh_advertise_endpoint = input.meshAdvertiseEndpoint.trim().replace(/\/$/, "");
+ if (primaryMeshAdvertiseEndpoint) {
+ scope.mesh_advertise_endpoint = primaryMeshAdvertiseEndpoint;
}
- scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_http";
+ if (meshEndpointCandidates.length > 0) {
+ scope.mesh_advertise_endpoints_json = JSON.stringify(meshEndpointCandidates);
+ }
+ scope.mesh_advertise_transport = input.meshAdvertiseTransport || "direct_quic";
scope.mesh_connectivity_mode = input.meshConnectivityMode || "outbound_only";
scope.mesh_nat_type = input.meshNATType || "unknown";
scope.mesh_region = input.meshRegion || "linux";
@@ -8901,6 +9603,7 @@ function joinTokenFormFromScope(scope: Record, fallback: JoinTo
meshListenAutoPortStart: numberField(scope, "mesh_listen_auto_port_start", fallback.meshListenAutoPortStart),
meshListenAutoPortEnd: numberField(scope, "mesh_listen_auto_port_end", fallback.meshListenAutoPortEnd),
meshAdvertiseEndpoint: stringField(scope, "mesh_advertise_endpoint", "") || fallback.meshAdvertiseEndpoint,
+ meshAdvertiseEndpoints: joinTokenAdvertiseEndpointsToText(scope) || fallback.meshAdvertiseEndpoints,
meshAdvertiseTransport: stringField(scope, "mesh_advertise_transport", fallback.meshAdvertiseTransport),
meshConnectivityMode: stringField(scope, "mesh_connectivity_mode", fallback.meshConnectivityMode),
meshNATType: stringField(scope, "mesh_nat_type", fallback.meshNATType),
@@ -8914,6 +9617,63 @@ function joinTokenFormFromScope(scope: Record, fallback: JoinTo
};
}
+function joinTokenAdvertiseEndpointsToText(scope: Record) {
+ const raw = stringField(scope, "mesh_advertise_endpoints_json", "");
+ if (!raw) {
+ return "";
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return "";
+ }
+ return parsed
+ .map((item) => {
+ const candidate = objectField(item);
+ if (!candidate) {
+ return "";
+ }
+ const address = stringField(candidate, "address", "");
+ if (!address) {
+ return "";
+ }
+ const parts = [address];
+ for (const key of ["reachability", "connectivity_mode", "nat_type", "region", "priority"] as const) {
+ const value = stringField(candidate, key, "");
+ if (value) {
+ parts.push(`${key.replace("_mode", "").replace("_type", "")}=${value}`);
+ }
+ }
+ const metadata = objectField(candidate.metadata);
+ for (const key of ["provider", "interface", "maps_to"] as const) {
+ const value = stringField(metadata, key, "");
+ if (value) {
+ parts.push(`${key}=${value}`);
+ }
+ }
+ return parts.join(" ");
+ })
+ .filter(Boolean)
+ .join("\n");
+ } catch {
+ return "";
+ }
+}
+
+function parseJoinTokenEndpointCandidates(input: JoinTokenFormState) {
+ return parseMeshListenerEndpointCandidates(
+ {
+ endpointCandidates: input.meshAdvertiseEndpoints,
+ advertiseEndpoint: input.meshAdvertiseEndpoint,
+ advertiseTransport: input.meshAdvertiseTransport || "direct_quic",
+ connectivity: input.meshConnectivityMode,
+ nat: input.meshNATType,
+ region: input.meshRegion,
+ },
+ input.nodeName.trim() || "install-node",
+ );
+}
+
function arrayStrings(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
}
@@ -8925,7 +9685,7 @@ function booleanField(source: Record, key: string, fallback: bo
function defaultControlPlaneEndpoint() {
if (typeof window === "undefined" || !window.location?.origin) {
- return "http://:18080/api/v1";
+ return "http://:18080/api/v1";
}
return `${window.location.origin.replace(/\/$/, "")}/api/v1`;
}
@@ -9100,7 +9860,7 @@ function windowsRepairUpdaterCmdCommand(node: ClusterNode, clusterID: string) {
`@echo off`,
`echo === RAP Windows updater repair: ${cmdEscape(nodeName)} ===`,
`echo Node ID: ${node.id}`,
- `echo Control Plane: ${endpoint}`,
+ `echo Control API endpoint: ${endpoint}`,
`echo.`,
`echo === Before repair: scheduled tasks ===`,
`schtasks /Query /TN "${taskName}" /V /FO LIST`,
@@ -9149,7 +9909,7 @@ function linuxRepairUpdaterShCommand(node: ClusterNode, clusterID: string) {
`set -euo pipefail`,
`echo "=== RAP Linux updater repair: ${shDoubleQuote(nodeName)} ==="`,
`echo "Node ID: ${node.id}"`,
- `echo "Control Plane: ${endpoint}"`,
+ `echo "Control API endpoint: ${endpoint}"`,
`echo`,
`echo "=== Before repair: systemd units ==="`,
`systemctl status ${shellQuote(unitName)} --no-pager || true`,
@@ -9247,7 +10007,7 @@ function installArtifactOrigin(form: JoinTokenFormState = defaultJoinTokenForm)
function hostAgentDownloadUrl(form: JoinTokenFormState = defaultJoinTokenForm) {
const origin =
typeof window === "undefined" && !form.controlPlaneEndpoint
- ? "http://:18080"
+ ? "http://:18080"
: installArtifactOrigin(form);
return `${origin}/downloads/rap-host-agent-linux-amd64`;
}
@@ -9255,7 +10015,7 @@ function hostAgentDownloadUrl(form: JoinTokenFormState = defaultJoinTokenForm) {
function hostAgentWindowsDownloadUrl(form: JoinTokenFormState = defaultJoinTokenForm) {
const origin =
typeof window === "undefined" && !form.controlPlaneEndpoint
- ? "http://:18080"
+ ? "http://:18080"
: installArtifactOrigin(form);
return `${origin}/downloads/rap-host-agent-windows-amd64.exe`;
}
@@ -9722,6 +10482,18 @@ function formatAgeSeconds(value?: number | null) {
return `${Math.round(value / 86400)}d ago`;
}
+function formatDateAgo(value?: string | null) {
+ if (!value) {
+ return "time n/a";
+ }
+ const timestamp = Date.parse(value);
+ if (Number.isNaN(timestamp)) {
+ return "time n/a";
+ }
+ const deltaSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
+ return formatAgeSeconds(deltaSeconds);
+}
+
function formatTime(value?: string | null) {
if (!value) {
return "н/д";
diff --git a/web-admin/src/api/client.ts b/web-admin/src/api/client.ts
index 35dfef3..05024f2 100644
--- a/web-admin/src/api/client.ts
+++ b/web-admin/src/api/client.ts
@@ -36,6 +36,7 @@ import type {
NodeSyntheticMeshConfig,
NodeTelemetryObservation,
NodeUpdatePlan,
+ NodeBridgeReplayPlan,
NodeUpdatePolicy,
NodeUpdateStatus,
NodeWorkloadDesiredState,
@@ -46,6 +47,7 @@ import type {
ReleaseVersion,
Resource,
RoleAssignment,
+ StaleNodeRiskReport,
UserAccount,
VPNClientDiagnosticCommand,
VPNClientDiagnosticStatus,
@@ -66,6 +68,7 @@ type ApiErrorPayload = {
message_key?: string;
fallback_message?: string;
trace_id?: string;
+ details?: Record;
};
};
@@ -106,6 +109,25 @@ export type UpsertNodeUpdatePolicyPayload = {
healthWindowSeconds?: number;
};
+export type CreateReleaseVersionPayload = {
+ product: string;
+ version: string;
+ channel?: string;
+ status?: string;
+ compatibility?: Record;
+ changelog?: string;
+ artifacts: Array<{
+ os: string;
+ arch: string;
+ installType: string;
+ kind: string;
+ url: string;
+ sha256: string;
+ sizeBytes?: number;
+ metadata?: Record;
+ }>;
+};
+
export type UpdateFabricServiceChannelRecoveryPolicyPayload = {
hysteresisPenalty?: number;
promotionMinSamples?: number;
@@ -436,6 +458,37 @@ export class AdminApiClient {
return payload.release_versions ?? [];
}
+ async createReleaseVersion(clusterId: string, input: CreateReleaseVersionPayload): Promise {
+ const payload = await this.post<{ release_version: ReleaseVersion }>(`/clusters/${clusterId}/updates/releases`, {
+ actor_user_id: this.actorUserId,
+ product: input.product,
+ version: input.version,
+ channel: input.channel || "stable",
+ status: input.status || "active",
+ compatibility: input.compatibility || {},
+ changelog: input.changelog || "",
+ artifacts: input.artifacts.map((artifact) => ({
+ os: artifact.os,
+ arch: artifact.arch,
+ install_type: artifact.installType,
+ kind: artifact.kind,
+ url: artifact.url,
+ sha256: artifact.sha256,
+ size_bytes: artifact.sizeBytes || 0,
+ metadata: artifact.metadata || {},
+ })),
+ });
+ return payload.release_version;
+ }
+
+ async getStaleNodeRiskReport(clusterId: string): Promise {
+ const params = new URLSearchParams({ actor_user_id: this.actorUserId });
+ const payload = await this.get<{ stale_node_risk_report: StaleNodeRiskReport }>(
+ `/clusters/${clusterId}/updates/stale-node-risk-report?${params.toString()}`,
+ );
+ return payload.stale_node_risk_report;
+ }
+
async getNodeUpdatePlan(
clusterId: string,
nodeId: string,
@@ -453,6 +506,14 @@ export class AdminApiClient {
return payload.node_update_plan;
}
+ async getNodeBridgeReplayPlan(clusterId: string, nodeId: string): Promise {
+ const params = new URLSearchParams({ actor_user_id: this.actorUserId });
+ const payload = await this.get<{ node_bridge_replay_plan: NodeBridgeReplayPlan }>(
+ `/clusters/${clusterId}/nodes/${nodeId}/updates/bridge-replay-plan?${params.toString()}`,
+ );
+ return payload.node_bridge_replay_plan;
+ }
+
async upsertNodeUpdatePolicy(clusterId: string, nodeId: string, input: UpsertNodeUpdatePolicyPayload): Promise {
const payload = await this.put<{ node_update_policy: NodeUpdatePolicy }>(`/clusters/${clusterId}/nodes/${nodeId}/updates/policy`, {
actor_user_id: this.actorUserId,
@@ -1269,7 +1330,7 @@ export class AdminApiClient {
let message = `Запрос завершился ошибкой HTTP ${response.status}`;
try {
const payload = (await response.json()) as ApiErrorPayload;
- message = payload.error?.fallback_message || payload.error?.code || message;
+ message = formatApiErrorMessage(payload, response.status) || payload.error?.fallback_message || payload.error?.code || message;
} catch {
// Keep generic HTTP message if backend did not return JSON.
}
@@ -1279,6 +1340,77 @@ export class AdminApiClient {
}
}
+function formatApiErrorMessage(payload: ApiErrorPayload, status: number) {
+ const error = payload.error;
+ if (!error) {
+ return "";
+ }
+ if (status === 409 && error.code === "conflict.legacy_compatibility_removal_is_blocked_while_stale_recovery_risk_nodes_remain") {
+ const details = error.details || {};
+ const parts: string[] = ["Compatibility cleanup заблокирован."];
+ const blockedOperation = stringDetail(details, "blocked_operation");
+ if (blockedOperation) {
+ parts.push(`Операция: ${blockedOperation}.`);
+ }
+ const counters = [
+ numberDetail(details, "blocked_nodes") ? `blockers ${numberDetail(details, "blocked_nodes")}` : "",
+ numberDetail(details, "stale_nodes") ? `stale ${numberDetail(details, "stale_nodes")}` : "",
+ numberDetail(details, "artifact_gap_nodes") ? `artifact gap ${numberDetail(details, "artifact_gap_nodes")}` : "",
+ numberDetail(details, "unknown_profile_nodes") ? `profile unknown ${numberDetail(details, "unknown_profile_nodes")}` : "",
+ numberDetail(details, "waiting_update_status_nodes") ? `waiting status ${numberDetail(details, "waiting_update_status_nodes")}` : "",
+ numberDetail(details, "unknown_version_nodes") ? `version unknown ${numberDetail(details, "unknown_version_nodes")}` : "",
+ numberDetail(details, "legacy_recovery_contract_nodes") ? `legacy contract ${numberDetail(details, "legacy_recovery_contract_nodes")}` : "",
+ numberDetail(details, "recovery_bridge_required_nodes") ? `recovery bridge ${numberDetail(details, "recovery_bridge_required_nodes")}` : "",
+ numberDetail(details, "recovery_bridge_replay_ready_nodes") ? `bridge replay ready ${numberDetail(details, "recovery_bridge_replay_ready_nodes")}` : "",
+ numberDetail(details, "waiting_recovery_heartbeat_nodes") ? `waiting heartbeat ${numberDetail(details, "waiting_recovery_heartbeat_nodes")}` : "",
+ ].filter(Boolean);
+ if (counters.length > 0) {
+ parts.push(counters.join(" / ") + ".");
+ }
+ const nodeIds = arrayDetail(details, "blocked_node_ids");
+ if (nodeIds.length > 0) {
+ parts.push(`Blocked nodes: ${nodeIds.join(", ")}.`);
+ }
+ if (booleanDetail(details, "bridge_hold_required")) {
+ const holdReasons = arrayDetail(details, "bridge_hold_reasons");
+ const holdNodes = arrayDetail(details, "bridge_hold_node_ids");
+ const holdSummary: string[] = [];
+ if (holdReasons.length > 0) {
+ holdSummary.push(`reasons ${holdReasons.join(", ")}`);
+ }
+ if (holdNodes.length > 0) {
+ holdSummary.push(`nodes ${holdNodes.join(", ")}`);
+ }
+ parts.push(`Recovery bridge hold active${holdSummary.length > 0 ? `: ${holdSummary.join(" / ")}` : ""}.`);
+ }
+ const traceID = error.trace_id?.trim();
+ if (traceID) {
+ parts.push(`Trace: ${traceID}.`);
+ }
+ return parts.join(" ");
+ }
+ return "";
+}
+
+function stringDetail(source: Record, key: string) {
+ const value = source[key];
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function numberDetail(source: Record, key: string) {
+ const value = source[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
+function booleanDetail(source: Record, key: string) {
+ return source[key] === true;
+}
+
+function arrayDetail(source: Record, key: string) {
+ const value = source[key];
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
+}
+
function browserDeviceFingerprint(): string {
const key = "rap.webAdmin.deviceFingerprint";
const existing = localStorage.getItem(key);
diff --git a/web-admin/src/types.ts b/web-admin/src/types.ts
index ba21bc4..a766dcb 100644
--- a/web-admin/src/types.ts
+++ b/web-admin/src/types.ts
@@ -343,6 +343,28 @@ export type NodeUpdatePlan = {
production_forwarding: boolean;
};
+export type NodeBridgeReplayProductPlan = {
+ product: string;
+ recovery_bridge_mode?: string;
+ recovery_bridge_replay_ready: boolean;
+ last_status_reason?: string;
+ update_plan: NodeUpdatePlan;
+};
+
+export type NodeBridgeReplayPlan = {
+ schema_version: string;
+ cluster_id: string;
+ node_id: string;
+ node_name?: string;
+ health_status?: string;
+ heartbeat_stale: boolean;
+ bridge_hold_required: boolean;
+ recovery_bridge_replay_ready: boolean;
+ bridge_hold_reasons?: string[];
+ bridge_actions?: string[];
+ products?: NodeBridgeReplayProductPlan[];
+};
+
export type NodeUpdatePolicy = {
id: string;
cluster_id: string;
@@ -374,6 +396,78 @@ export type NodeUpdateStatus = {
observed_at: string;
};
+export type StaleNodeRiskProduct = {
+ product: string;
+ current_version?: string | null;
+ target_version?: string | null;
+ channel?: string | null;
+ strategy?: string | null;
+ enabled?: boolean;
+ detected_os?: string | null;
+ detected_arch?: string | null;
+ detected_install_type?: string | null;
+ compatible_artifact_found: boolean;
+ matching_release_version?: string | null;
+ last_status_observed_at?: string | null;
+ last_status_phase?: string | null;
+ last_status_value?: string | null;
+ last_status_reason?: string | null;
+ recovery_bridge_required?: boolean;
+ recovery_bridge_replay_ready?: boolean;
+ recovery_bridge_mode?: string | null;
+ risks?: string[];
+};
+
+export type StaleNodeRiskNode = {
+ node_id: string;
+ name: string;
+ node_key?: string;
+ reported_version?: string | null;
+ health_status: string;
+ registration_status: string;
+ last_seen_at?: string | null;
+ heartbeat_stale: boolean;
+ blocked: boolean;
+ direct_peer_alert?: boolean;
+ direct_peer_ready_count?: number;
+ direct_peer_target_count?: number;
+ direct_peer_deficit?: number;
+ alerts?: string[];
+ recovery_bridge_required?: boolean;
+ recovery_bridge_replay_ready?: boolean;
+ recovery_bridge_actions?: string[];
+ risks: string[];
+ products: StaleNodeRiskProduct[];
+};
+
+export type StaleNodeRiskSummary = {
+ total_nodes: number;
+ stale_nodes: number;
+ blocked_nodes: number;
+ direct_peer_alert_nodes?: number;
+ artifact_gap_nodes?: number;
+ unknown_profile_nodes?: number;
+ waiting_update_status_nodes?: number;
+ unknown_version_nodes?: number;
+ legacy_recovery_contract_nodes?: number;
+ recovery_bridge_required_nodes?: number;
+ recovery_bridge_replay_ready_nodes?: number;
+ waiting_recovery_heartbeat_nodes?: number;
+};
+
+export type StaleNodeRiskReport = {
+ cluster_id: string;
+ generated_at: string;
+ heartbeat_stale_after_seconds?: number;
+ legacy_removal_allowed: boolean;
+ bridge_hold_required?: boolean;
+ bridge_hold_node_ids?: string[];
+ bridge_hold_reasons?: string[];
+ blocked_operations?: string[];
+ summary: StaleNodeRiskSummary;
+ nodes: StaleNodeRiskNode[];
+};
+
export type MeshLink = {
id: string;
cluster_id: string;
@@ -1196,6 +1290,8 @@ export type NodeSyntheticMeshConfig = {
auto_port_start?: number;
auto_port_end?: number;
advertise_endpoint?: string;
+ advertise_endpoints?: string[];
+ endpoint_candidates?: PeerEndpointCandidate[];
advertise_transport?: string;
connectivity_mode?: string;
nat_type?: string;