This commit is contained in:
2026-05-18 21:33:39 +03:00
parent 5096155d83
commit 469fa0e860
94 changed files with 8761 additions and 8003 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Панель Secure Access Fabric</title>
<script type="module" crossorigin src="/assets/index-gMV--oab.js"></script>
<script type="module" crossorigin src="/assets/index-CiNvRobk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cur_BAkX.css">
</head>
<body>
+827 -55
View File
File diff suppressed because it is too large Load Diff
+133 -1
View File
@@ -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<string, unknown>;
};
};
@@ -106,6 +109,25 @@ export type UpsertNodeUpdatePolicyPayload = {
healthWindowSeconds?: number;
};
export type CreateReleaseVersionPayload = {
product: string;
version: string;
channel?: string;
status?: string;
compatibility?: Record<string, unknown>;
changelog?: string;
artifacts: Array<{
os: string;
arch: string;
installType: string;
kind: string;
url: string;
sha256: string;
sizeBytes?: number;
metadata?: Record<string, unknown>;
}>;
};
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<ReleaseVersion> {
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<StaleNodeRiskReport> {
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<NodeBridgeReplayPlan> {
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<NodeUpdatePolicy> {
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<string, unknown>, key: string) {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function numberDetail(source: Record<string, unknown>, key: string) {
const value = source[key];
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function booleanDetail(source: Record<string, unknown>, key: string) {
return source[key] === true;
}
function arrayDetail(source: Record<string, unknown>, 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);
+96
View File
@@ -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;