{{.Cluster.Name}}
{{.Cluster.Slug}} · {{.Cluster.Status}} · обновлено {{.GeneratedAt}}
Состояние узлов
| {{.Name}}{{.ReportedVersion}} | {{.Freshness}} | {{.OperationalState}} |
package cluster import ( "encoding/json" "html/template" "math" "net/http" "sort" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/example/remote-access-platform/backend/internal/platform/httpx" ) type consoleNavItem struct { Label string URL string Active bool } type farmOverviewHTMLPage struct { Cluster Cluster ActorUserID string GeneratedAt string Nav []consoleNavItem Metrics []consoleMetricHTML Nodes []nodesHTMLRow Links []fabricLinkHTML Events []auditEventHTML } type fabricConsoleHTMLPage struct { Cluster Cluster ActorUserID string GeneratedAt string Nav []consoleNavItem Readiness FabricServiceChannelReadiness RebuildHealth FabricServiceChannelRouteRebuildHealthSummary Access FabricServiceChannelAccessTelemetry Recovery FabricServiceChannelRecoveryPolicy Adaptive FabricServiceChannelAdaptivePolicy Pool FabricServiceChannelPoolPolicy Links []fabricLinkHTML Routes []MeshRouteIntent Channels []FabricServiceChannelAccessTelemetryChannel Result *webControlResultHTML } type topologyHTMLPage struct { Cluster Cluster ActorUserID string GeneratedAt string Nav []consoleNavItem Summary []consoleMetricHTML Filters []topologyFilterHTML FilterGroups []topologyFilterGroupHTML Insights []topologyInsightHTML Legend []topologyLegendHTML Nodes []topologyNodeHTML Links []topologyLinkHTML Rows []fabricLinkHTML } type topologyFilterHTML struct { Key string Label string Hint string Checked bool Count int } type topologyFilterGroupHTML struct { Title string InputName string Filters []topologyFilterHTML } type topologyInsightHTML struct { Title string Value string Tone string Sub string } type topologyLegendHTML struct { Name string Tone string Text string } type webControlHTMLPage struct { Cluster Cluster ActorUserID string GeneratedAt string Nav []consoleNavItem Rows []webControlNodeHTML Result *webControlResultHTML } type auditConsoleHTMLPage struct { Cluster Cluster ActorUserID string GeneratedAt string Nav []consoleNavItem Summary ClusterAuditSummary Events []auditEventHTML } type consoleMetricHTML struct { Label string Value string Sub string Tone string } type fabricLinkHTML struct { Source string Target string Status string Tone string Mode string Observation string Latency string Quality string ObservedAt string } type topologyNodeHTML struct { ID string Name string Tone string Status string Version string X int Y int } type topologyLinkHTML struct { SourceName string TargetName string Kind string TransportClass string Label string Tone string Status string Latency string Quality string Path string MarkerURL string Tooltip string } type topologyLinkCandidate struct { SourceID string TargetID string Kind string TransportClass string Label string Tone string Status string Latency string Quality string Tooltip string Suspicious bool Observed time.Time } type auditEventHTML struct { Type string Target string Tone string CreatedAt string Summary string } type webControlNodeHTML struct { NodeID string NodeName string Freshness string FreshTone string Admin webControlServiceHTML Public webControlServiceHTML } type webControlServiceHTML struct { ServiceType string DesiredState string DesiredTone string RuntimeMode string Version string ReportedState string ReportedTone string ObservedAt string } type webControlResultHTML struct { Tone string Title string Message string } func (m *Module) renderFarmOverviewHTML(w http.ResponseWriter, r *http.Request) { page, err := m.farmOverviewHTMLModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := farmOverviewHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render farm overview") } } func (m *Module) renderFabricConsoleHTML(w http.ResponseWriter, r *http.Request) { page, err := m.fabricConsoleHTMLModel(r, nil) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := fabricConsoleHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render fabric console") } } func (m *Module) renderTopologyHTML(w http.ResponseWriter, r *http.Request) { page, err := m.topologyHTMLModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := topologyHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render topology") } } func (m *Module) renderFabricPolicyHTML(w http.ResponseWriter, r *http.Request) { result, err := m.applyFabricPolicy(r) if writeServiceError(w, err) { return } page, err := m.fabricConsoleHTMLModel(r, &result) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := fabricConsoleFragmentHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render fabric policy fragment") } } func (m *Module) renderWebControlHTML(w http.ResponseWriter, r *http.Request) { page, err := m.webControlHTMLModel(r, nil) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := webControlHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render web control") } } func (m *Module) renderWebControlDesiredHTML(w http.ResponseWriter, r *http.Request) { result, err := m.applyWebControlDesired(r) if writeServiceError(w, err) { return } page, err := m.webControlHTMLModel(r, &result) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := webControlFragmentHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render web control fragment") } } func (m *Module) renderAuditConsoleHTML(w http.ResponseWriter, r *http.Request) { page, err := m.auditConsoleHTMLModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := auditConsoleHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render audit console") } } func (m *Module) farmOverviewHTMLModel(r *http.Request) (farmOverviewHTMLPage, error) { clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) cluster, nodes, err := m.consoleClusterAndNodes(r, actorUserID, clusterID) if err != nil { return farmOverviewHTMLPage{}, err } now := time.Now().UTC() rows := make([]nodesHTMLRow, 0, len(nodes)) summary := nodesHTMLSummary{Total: len(nodes)} for _, node := range nodes { row := nodesHTMLRowFromNode(node, now) rows = append(rows, row) if row.FreshnessTone == "good" { summary.Fresh++ } if row.VersionTone == "good" { summary.Current++ } if row.OperationalTone != "good" { summary.NeedsReview++ } } sort.SliceStable(rows, func(i, j int) bool { return toneRank(rows[i].OperationalTone) < toneRank(rows[j].OperationalTone) }) readiness, _ := m.service.GetFabricServiceChannelReadiness(r.Context(), actorUserID, GetFabricServiceChannelReadinessInput{ClusterID: clusterID, Limit: 30}) access, _ := m.service.GetFabricServiceChannelAccessTelemetry(r.Context(), actorUserID, GetFabricServiceChannelAccessTelemetryInput{ClusterID: clusterID, Limit: 12}) links, _ := m.service.ListMeshLinks(r.Context(), actorUserID, clusterID) events, _ := m.service.ListAuditEvents(r.Context(), actorUserID, ListAuditEventsInput{ClusterID: clusterID, Limit: 8}) page := farmOverviewHTMLPage{ Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(now), Nav: consoleNav(clusterID, actorUserID, "overview"), Nodes: rows, Links: fabricLinkRows(links, 10), Events: auditEventRows(events), } page.Metrics = []consoleMetricHTML{ {"узлы", displayInt(summary.Total), displayInt(summary.Fresh) + " со свежим отчетом", toneFromCount(summary.NeedsReview)}, {"версии", displayInt(summary.Current), displayInt(summary.Total-summary.Current) + " требуют синхронизации", toneFromCount(summary.Total - summary.Current)}, {"fabric", displayStatus(readiness.Status), displayStatus(readiness.Reason), statusTone(readiness.Status)}, {"каналы", displayInt(access.ActiveChannelCount), "accepted " + displayInt(access.TotalAccepted), statusTone(access.Status)}, } return page, nil } func (m *Module) fabricConsoleHTMLModel(r *http.Request, result *webControlResultHTML) (fabricConsoleHTMLPage, error) { clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) cluster, _, err := m.consoleClusterAndNodes(r, actorUserID, clusterID) if err != nil { return fabricConsoleHTMLPage{}, err } readiness, err := m.service.GetFabricServiceChannelReadiness(r.Context(), actorUserID, GetFabricServiceChannelReadinessInput{ClusterID: clusterID, Limit: 80}) if err != nil { return fabricConsoleHTMLPage{}, err } health, _ := m.service.GetFabricServiceChannelRouteRebuildHealthSummary(r.Context(), actorUserID, GetFabricServiceChannelRouteRebuildHealthSummaryInput{ClusterID: clusterID, Limit: 80}) access, _ := m.service.GetFabricServiceChannelAccessTelemetry(r.Context(), actorUserID, GetFabricServiceChannelAccessTelemetryInput{ClusterID: clusterID, Limit: 40}) recovery, _ := m.service.GetFabricServiceChannelRecoveryPolicy(r.Context(), actorUserID, clusterID) adaptive, _ := m.service.GetFabricServiceChannelAdaptivePolicy(r.Context(), actorUserID, clusterID) pool, _ := m.service.GetFabricServiceChannelPoolPolicy(r.Context(), actorUserID, clusterID) links, _ := m.service.ListMeshLinks(r.Context(), actorUserID, clusterID) routes, _ := m.service.ListRouteIntents(r.Context(), actorUserID, clusterID) return fabricConsoleHTMLPage{ Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(time.Now().UTC()), Nav: consoleNav(clusterID, actorUserID, "fabric"), Readiness: readiness, RebuildHealth: health, Access: access, Recovery: recovery, Adaptive: adaptive, Pool: pool, Links: fabricLinkRows(links, 18), Routes: routes, Channels: access.ActiveChannels, Result: result, }, nil } func (m *Module) topologyHTMLModel(r *http.Request) (topologyHTMLPage, error) { clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) cluster, nodes, err := m.consoleClusterAndNodes(r, actorUserID, clusterID) if err != nil { return topologyHTMLPage{}, err } links, _ := m.service.ListMeshLinks(r.Context(), actorUserID, clusterID) now := time.Now().UTC() topologyNodes, nodeByID := topologyNodeRows(nodes, now) candidates := m.topologyLinkCandidates(r, clusterID, nodes, links) filters := topologyFiltersFromQuery(r) stateFilters := topologyStateFiltersFromQuery(r) topologyLinks := topologyLinkRows(candidates, nodeByID, filters, stateFilters) insights := topologyInsights(candidates, nodeByID) factReachable, factProblem, plannedRoutes := topologyFactSummary(candidates) return topologyHTMLPage{ Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(now), Nav: consoleNav(clusterID, actorUserID, "topology"), Filters: topologyFilterRows(candidates, filters), FilterGroups: topologyFilterGroups(candidates, filters, stateFilters), Insights: insights, Legend: topologyLegendRows(), Nodes: topologyNodes, Links: topologyLinks, Rows: fabricLinkRows(links, 80), Summary: []consoleMetricHTML{ {"узлы", displayInt(len(topologyNodes)), "на карте", toneFromCount(0)}, {"факт", displayInt(factReachable), "reachable наблюдения", "good"}, {"план", displayInt(plannedRoutes), "route hops в config", "muted"}, {"проверить", displayInt(factProblem), "degraded/unreachable факт", toneFromCount(factProblem)}, }, }, nil } func (m *Module) webControlHTMLModel(r *http.Request, result *webControlResultHTML) (webControlHTMLPage, error) { clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) cluster, nodes, err := m.consoleClusterAndNodes(r, actorUserID, clusterID) if err != nil { return webControlHTMLPage{}, err } now := time.Now().UTC() page := webControlHTMLPage{ Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(now), Nav: consoleNav(clusterID, actorUserID, "web-control"), Result: result, Rows: make([]webControlNodeHTML, 0, len(nodes)), } for _, node := range nodes { row := nodesHTMLRowFromNode(node, now) desired, _ := m.service.ListDesiredWorkloads(r.Context(), actorUserID, clusterID, node.ID) statuses, _ := m.service.ListLatestWorkloadStatuses(r.Context(), actorUserID, clusterID, node.ID) page.Rows = append(page.Rows, webControlNodeHTML{ NodeID: node.ID, NodeName: node.Name, Freshness: row.Freshness, FreshTone: row.FreshnessTone, Admin: webControlServiceFrom("admin-ingress", desired, statuses), Public: webControlServiceFrom("public-ingress", desired, statuses), }) } return page, nil } func (m *Module) auditConsoleHTMLModel(r *http.Request) (auditConsoleHTMLPage, error) { clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) cluster, _, err := m.consoleClusterAndNodes(r, actorUserID, clusterID) if err != nil { return auditConsoleHTMLPage{}, err } events, err := m.service.ListAuditEvents(r.Context(), actorUserID, ListAuditEventsInput{ClusterID: clusterID, Limit: 80}) if err != nil { return auditConsoleHTMLPage{}, err } return auditConsoleHTMLPage{ Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(time.Now().UTC()), Nav: consoleNav(clusterID, actorUserID, "audit"), Events: auditEventRows(events), Summary: summarizeClusterAuditEvents(events), }, nil } func (m *Module) applyWebControlDesired(r *http.Request) (webControlResultHTML, error) { if err := r.ParseForm(); err != nil { return webControlResultHTML{}, ErrInvalidPayload } clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) nodeID := strings.TrimSpace(r.FormValue("node_id")) serviceType := strings.TrimSpace(r.FormValue("service_type")) desired := strings.TrimSpace(r.FormValue("desired_state")) runtimeMode := strings.TrimSpace(r.FormValue("runtime_mode")) version := strings.TrimSpace(r.FormValue("version")) if serviceType != "admin-ingress" && serviceType != "public-ingress" { return webControlResultHTML{}, ErrInvalidPayload } var versionPtr *string if version != "" { versionPtr = &version } _, err := m.service.SetDesiredWorkload(r.Context(), SetDesiredWorkloadInput{ ActorUserID: actorUserID, ClusterID: clusterID, NodeID: nodeID, ServiceType: serviceType, DesiredState: firstNonEmptyString(desired, "disabled"), RuntimeMode: firstNonEmptyString(runtimeMode, "container"), Version: versionPtr, Config: json.RawMessage(`{"control_plane":"quic_fabric"}`), Environment: json.RawMessage(`{}`), }) if err != nil { return webControlResultHTML{}, err } return webControlResultHTML{Tone: "good", Title: "настройка сохранена", Message: serviceType + " на узле обновлен через desired state"}, nil } func (m *Module) applyFabricPolicy(r *http.Request) (webControlResultHTML, error) { if err := r.ParseForm(); err != nil { return webControlResultHTML{}, ErrInvalidPayload } clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id")) switch strings.TrimSpace(r.FormValue("policy")) { case "recovery": rebuild := formBoolPtr(r, "demotion_rebuild_enabled") fenced := formBoolPtr(r, "demotion_fenced_enabled") _, err := m.service.UpdateFabricServiceChannelRecoveryPolicy(r.Context(), UpdateFabricServiceChannelRecoveryPolicyInput{ ActorUserID: actorUserID, ClusterID: clusterID, HysteresisPenalty: formInt(r, "hysteresis_penalty"), PromotionMinSamples: formInt(r, "promotion_min_samples"), DemotionFailureThreshold: formInt(r, "demotion_failure_threshold"), DemotionDropThreshold: formInt(r, "demotion_drop_threshold"), DemotionSlowThreshold: formInt(r, "demotion_slow_threshold"), DemotionRebuildEnabled: rebuild, DemotionFencedEnabled: fenced, }) if err != nil { return webControlResultHTML{}, err } return webControlResultHTML{Tone: "good", Title: "recovery policy сохранена", Message: "изменения записаны в cluster metadata и подписываются control-plane"}, nil case "adaptive": _, err := m.service.UpdateFabricServiceChannelAdaptivePolicy(r.Context(), UpdateFabricServiceChannelAdaptivePolicyInput{ ActorUserID: actorUserID, ClusterID: clusterID, MaxParallelWindow: formInt(r, "max_parallel_window"), BulkPressureChannelThreshold: formInt(r, "bulk_pressure_channel_threshold"), QueuePressureHighWatermark: formInt(r, "queue_pressure_high_watermark"), QueuePressureMaxInFlight: formInt(r, "queue_pressure_max_in_flight"), }) if err != nil { return webControlResultHTML{}, err } return webControlResultHTML{Tone: "good", Title: "adaptive policy сохранена", Message: "новые пределы backpressure применяются к fabric service channels"}, nil case "pool": degraded := formBoolPtr(r, "degraded_route_allowed") sticky := formBoolPtr(r, "sticky_session") _, err := m.service.UpdateFabricServiceChannelPoolPolicy(r.Context(), UpdateFabricServiceChannelPoolPolicyInput{ ActorUserID: actorUserID, ClusterID: clusterID, EntryPoolNodeIDs: formCSV(r, "entry_pool_node_ids"), ExitPoolNodeIDs: formCSV(r, "exit_pool_node_ids"), PreferredEntryNodeID: strings.TrimSpace(r.FormValue("preferred_entry_node_id")), PreferredExitNodeID: strings.TrimSpace(r.FormValue("preferred_exit_node_id")), SelectionStrategy: strings.TrimSpace(r.FormValue("selection_strategy")), RouteRebuild: strings.TrimSpace(r.FormValue("route_rebuild")), EntryFailover: strings.TrimSpace(r.FormValue("entry_failover")), ExitFailover: strings.TrimSpace(r.FormValue("exit_failover")), CompatFallbackAllowed: degraded, StickySession: sticky, }) if err != nil { return webControlResultHTML{}, err } return webControlResultHTML{Tone: "good", Title: "pool policy сохранена", Message: "пулы входа/выхода и rebuild policy обновлены"}, nil default: return webControlResultHTML{}, ErrInvalidPayload } } func (m *Module) consoleClusterAndNodes(r *http.Request, actorUserID, clusterID string) (Cluster, []ClusterNode, error) { cluster, err := m.service.GetCluster(r.Context(), actorUserID, clusterID) if err != nil { return Cluster{}, nil, err } nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID) if err != nil { return Cluster{}, nil, err } return cluster, nodes, nil } func consoleNav(clusterID, actorUserID, active string) []consoleNavItem { query := "" if strings.TrimSpace(actorUserID) != "" { query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID) } items := []struct{ key, label, path string }{ {"overview", "Обзор", "overview"}, {"nodes", "Узлы", "nodes"}, {"updates", "Обновления", "updates"}, {"topology", "Топология", "topology"}, {"fabric", "Fabric", "fabric"}, {"web-control", "Веб-контроль", "web-control"}, {"audit", "Аудит", "audit"}, } out := make([]consoleNavItem, 0, len(items)) for _, item := range items { out = append(out, consoleNavItem{Label: item.label, URL: "/api/v1/clusters/" + clusterID + "/ui/" + item.path + query, Active: item.key == active}) } return out } func topologyNodeRows(nodes []ClusterNode, now time.Time) ([]topologyNodeHTML, map[string]topologyNodeHTML) { out := make([]topologyNodeHTML, 0, len(nodes)) nodeByID := map[string]topologyNodeHTML{} if len(nodes) == 0 { return out, nodeByID } sort.SliceStable(nodes, func(i, j int) bool { return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name) }) centerX, centerY := 500.0, 310.0 radiusX, radiusY := 360.0, 220.0 if len(nodes) == 1 { radiusX, radiusY = 0, 0 } for index, node := range nodes { angle := (2 * math.Pi * float64(index) / float64(len(nodes))) - math.Pi/2 row := nodesHTMLRowFromNode(node, now) item := topologyNodeHTML{ ID: node.ID, Name: node.Name, Tone: row.OperationalTone, Status: row.OperationalState, Version: row.ReportedVersion, X: int(math.Round(centerX + radiusX*math.Cos(angle))), Y: int(math.Round(centerY + radiusY*math.Sin(angle))), } out = append(out, item) nodeByID[item.ID] = item } return out, nodeByID } func (m *Module) topologyLinkCandidates(r *http.Request, clusterID string, nodes []ClusterNode, observed []MeshLinkObservation) []topologyLinkCandidate { out := make([]topologyLinkCandidate, 0, len(observed)) perspectives := m.topologyEndpointPerspectives(r, clusterID, nodes) for _, link := range observed { latency, quality := "нет", "нет" if link.LatencyMs != nil { latency = displayInt(*link.LatencyMs) + " ms" } if link.QualityScore != nil { quality = displayInt(*link.QualityScore) } status := displayStatus(link.LinkStatus) metadata := meshObservationMetadata(link.Metadata) transport := firstNonEmptyString(metadata["transport_mode"], metadata["transport"], "unknown") observationType := firstNonEmptyString(metadata["observation_type"], "mesh_probe") connectionState := firstNonEmptyString(metadata["connection_state"], metadata["state"]) relayNodeID := metadata["relay_node_id"] rendezvousLeaseID := metadata["rendezvous_lease_id"] failureReason := metadata["failure_reason"] transportClass := topologyTransportClass(transport, observationType) tone := statusTone(link.LinkStatus) suspicious := topologySuspiciousDirectInbound(link, transport, perspectives[link.TargetNodeID]) if suspicious { tone = "bad" transportClass += " transport-suspicious" } tooltipParts := []string{ "фактическая QUIC fabric связь узел-узел", "статус: " + status, "transport_mode: " + transport, "observation_type: " + observationType, "latency: " + latency, "quality: " + quality, "наблюдение: " + consoleTime(link.ObservedAt), } if connectionState != "" { tooltipParts = append(tooltipParts, "connection_state: "+connectionState) } if relayNodeID != "" { tooltipParts = append(tooltipParts, "relay_node_id: "+relayNodeID) } if rendezvousLeaseID != "" { tooltipParts = append(tooltipParts, "rendezvous_lease_id: "+rendezvousLeaseID) } if failureReason != "" { tooltipParts = append(tooltipParts, "failure_reason: "+failureReason) } if suspicious { tooltipParts = append(tooltipParts, "подозрение: входящий direct к NAT/outbound-only узлу; для такого узла нормальна исходящая связь или relay/rendezvous") } out = append(out, topologyLinkCandidate{ SourceID: link.SourceNodeID, TargetID: link.TargetNodeID, Kind: "observed", TransportClass: transportClass, Label: "transport fact", Tone: tone, Status: status, Latency: latency, Quality: quality, Suspicious: suspicious, Observed: link.ObservedAt, Tooltip: strings.Join(tooltipParts, "\n"), }) } for _, node := range nodes { cfg, err := m.service.GetNodeSyntheticMeshConfig(r.Context(), GetNodeSyntheticMeshConfigInput{ClusterID: clusterID, NodeID: node.ID}) if err != nil || !cfg.Enabled { continue } out = append(out, topologyRouteCandidates(node.ID, cfg)...) out = append(out, topologyDirectoryCandidates(node.ID, cfg)...) out = append(out, topologyEndpointCandidates(node.ID, cfg)...) out = append(out, topologyRecoverySeedCandidates(node.ID, cfg)...) out = append(out, topologyRendezvousCandidates(node.ID, cfg)...) } return out } type topologyEndpointPerspective struct { OutboundOnly bool ConnectivityMode string NATType string PeerEndpoint string Candidates []PeerEndpointCandidate } func (m *Module) topologyEndpointPerspectives(r *http.Request, clusterID string, nodes []ClusterNode) map[string]topologyEndpointPerspective { out := make(map[string]topologyEndpointPerspective, len(nodes)) for _, node := range nodes { heartbeats, err := m.service.ListNodeHeartbeats(r.Context(), r.URL.Query().Get("actor_user_id"), clusterID, node.ID, 1) if err != nil || len(heartbeats) == 0 { continue } out[node.ID] = topologyEndpointPerspectiveFromHeartbeat(heartbeats[0]) } return out } func topologyEndpointPerspectiveFromHeartbeat(heartbeat NodeHeartbeat) topologyEndpointPerspective { var metadata struct { MeshEndpointReport heartbeatMeshEndpointReport `json:"mesh_endpoint_report"` FabricListenerReport struct { InboundReachability string `json:"inbound_reachability"` OneWayConnectivity bool `json:"one_way_connectivity"` } `json:"fabric_listener_report"` } if len(heartbeat.Metadata) == 0 || !json.Valid(heartbeat.Metadata) { return topologyEndpointPerspective{} } if err := json.Unmarshal(heartbeat.Metadata, &metadata); err != nil { return topologyEndpointPerspective{} } return topologyEndpointPerspective{ OutboundOnly: metadata.FabricListenerReport.OneWayConnectivity || strings.EqualFold(metadata.FabricListenerReport.InboundReachability, "outbound_only") || strings.EqualFold(metadata.MeshEndpointReport.ConnectivityMode, "outbound_only"), ConnectivityMode: strings.TrimSpace(metadata.MeshEndpointReport.ConnectivityMode), NATType: strings.TrimSpace(metadata.MeshEndpointReport.NATType), PeerEndpoint: strings.TrimSpace(metadata.MeshEndpointReport.PeerEndpoint), Candidates: metadata.MeshEndpointReport.EndpointCandidates, } } func meshObservationMetadata(raw json.RawMessage) map[string]string { out := map[string]string{} if len(raw) == 0 || !json.Valid(raw) { return out } values := map[string]any{} if err := json.Unmarshal(raw, &values); err != nil { return out } for _, key := range []string{"transport_mode", "transport", "observation_type", "connection_state", "state", "relay_node_id", "rendezvous_lease_id", "failure_reason"} { out[key] = meshMetadataString(values, key) } return out } func topologyTransportClass(transport, observationType string) string { value := strings.ToLower(strings.TrimSpace(firstNonEmptyString(transport, observationType))) switch { case strings.Contains(value, "relay") || strings.Contains(strings.ToLower(observationType), "relay"): return "transport-relay" case strings.Contains(value, "private_lan"): return "transport-private-lan" case strings.Contains(value, "direct"): return "transport-direct" default: return "transport-unknown" } } func topologySuspiciousDirectInbound(link MeshLinkObservation, transport string, target topologyEndpointPerspective) bool { if link.SourceNodeID == "" || link.TargetNodeID == "" || link.SourceNodeID == link.TargetNodeID || !strings.EqualFold(displayStatus(link.LinkStatus), "reachable") { return false } value := strings.ToLower(strings.TrimSpace(transport)) if strings.Contains(value, "relay") || strings.Contains(value, "rendezvous") { return false } if !strings.Contains(value, "direct") && !strings.Contains(value, "private_lan") { return false } return topologyTargetNeedsOutboundOrRelay(target) } func topologyTargetNeedsOutboundOrRelay(target topologyEndpointPerspective) bool { connectivity := strings.ToLower(strings.TrimSpace(target.ConnectivityMode)) natType := strings.ToLower(strings.TrimSpace(target.NATType)) if target.OutboundOnly || connectivity == "outbound_only" || connectivity == "relay_required" || natType == "symmetric" || natType == "blocked" { return true } if target.PeerEndpoint == "" && len(target.Candidates) == 0 { return true } return false } func topologyRouteCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate { out := []topologyLinkCandidate{} for _, route := range cfg.Routes { path := cleanRouteNodePath(route.Hops) if len(path) < 2 { continue } for index := 0; index < len(path)-1; index++ { channels := strings.Join(route.AllowedChannels, ", ") out = append(out, topologyLinkCandidate{ SourceID: path[index], TargetID: path[index+1], Kind: "route", Label: "план маршрута", Tone: "muted", Status: firstNonEmptyString(channels, "route"), Observed: route.ExpiresAt, Tooltip: "запланированный маршрут\nroute: " + route.RouteID + "\nchannels: " + channels + "\nhop: " + displayInt(index+1) + " из " + displayInt(len(path)-1) + "\nисточник конфига: " + localNodeID, }) } } return out } func topologyDirectoryCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate { out := []topologyLinkCandidate{} for _, entry := range cfg.PeerDirectory { if entry.NodeID == "" || entry.NodeID == localNodeID { continue } tone := "good" if entry.EndpointCount == 0 && entry.CandidateCount == 0 { tone = "warn" } status := displayInt(entry.EndpointCount) + "/" + displayInt(entry.CandidateCount) out = append(out, topologyLinkCandidate{ SourceID: localNodeID, TargetID: entry.NodeID, Kind: "directory", Label: "память узла", Tone: tone, Status: status, Tooltip: "peer directory\nузел хранит запись о peer\nendpoints: " + displayInt(entry.EndpointCount) + "\ncandidates: " + displayInt(entry.CandidateCount) + "\nmodes: " + strings.Join(entry.ConnectivityModes, ", ") + "\nroutes: " + strings.Join(entry.RouteIDs, ", "), }) } return out } func topologyEndpointCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate { out := []topologyLinkCandidate{} for peerNodeID, candidates := range cfg.PeerEndpointCandidates { if peerNodeID == "" || peerNodeID == localNodeID || len(candidates) == 0 { continue } best := candidates[0] for _, candidate := range candidates[1:] { if candidate.Priority < best.Priority { best = candidate } } tone := "good" if endpointCandidateRequiresRendezvous(best) { tone = "warn" } out = append(out, topologyLinkCandidate{ SourceID: localNodeID, TargetID: peerNodeID, Kind: "endpoint", Label: "адрес-кандидат", Tone: tone, Status: firstNonEmptyString(best.ConnectivityMode, best.Reachability), Tooltip: "endpoint candidates\nузел знает адреса peer\ntransport: " + best.Transport + "\nmode: " + best.ConnectivityMode + "\nreachability: " + best.Reachability + "\nregion: " + best.Region + "\nкандидатов: " + displayInt(len(candidates)), }) } return out } func topologyRecoverySeedCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate { out := []topologyLinkCandidate{} for _, seed := range cfg.RecoverySeeds { if seed.NodeID == "" || seed.NodeID == localNodeID { continue } out = append(out, topologyLinkCandidate{ SourceID: localNodeID, TargetID: seed.NodeID, Kind: "recovery", Label: "recovery", Tone: "warn", Status: firstNonEmptyString(seed.ConnectivityMode, seed.Transport), Observed: timeValue(seed.LastVerifiedAt), Tooltip: "recovery seed\nрезервная точка восстановления связи\ntransport: " + seed.Transport + "\nmode: " + seed.ConnectivityMode + "\nregion: " + seed.Region + "\nverified: " + consoleTime(timeValue(seed.LastVerifiedAt)), }) } return out } func topologyRendezvousCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate { out := []topologyLinkCandidate{} for _, lease := range cfg.RendezvousLeases { if lease.RelayNodeID != "" && lease.RelayNodeID != localNodeID { out = append(out, topologyLinkCandidate{ SourceID: localNodeID, TargetID: lease.RelayNodeID, Kind: "rendezvous", Label: "rendezvous", Tone: "warn", Status: firstNonEmptyString(lease.ConnectivityMode, lease.Transport), Observed: lease.IssuedAt, Tooltip: "rendezvous lease\nузел использует relay для связи через NAT\npeer: " + lease.PeerNodeID + "\nrelay: " + lease.RelayNodeID + "\nroutes: " + strings.Join(lease.RouteIDs, ", ") + "\nexpires: " + consoleTime(lease.ExpiresAt), }) } if lease.RelayNodeID != "" && lease.PeerNodeID != "" && lease.RelayNodeID != lease.PeerNodeID { out = append(out, topologyLinkCandidate{ SourceID: lease.RelayNodeID, TargetID: lease.PeerNodeID, Kind: "rendezvous", Label: "rendezvous", Tone: "warn", Status: firstNonEmptyString(lease.ConnectivityMode, lease.Transport), Observed: lease.IssuedAt, Tooltip: "rendezvous relay path\nrelay отдает связь peer\nзапросивший узел: " + localNodeID + "\nroutes: " + strings.Join(lease.RouteIDs, ", ") + "\nexpires: " + consoleTime(lease.ExpiresAt), }) } } return out } func timeValue(value *time.Time) time.Time { if value == nil { return time.Time{} } return *value } func topologyFiltersFromQuery(r *http.Request) map[string]bool { selected := map[string]bool{} values := append([]string{}, r.URL.Query()["filter"]...) values = append(values, r.URL.Query()["link"]...) if len(values) == 0 { for _, key := range topologyFilterKeyOrder() { selected[key] = true } return selected } for _, value := range values { value = strings.TrimSpace(value) if value != "" { selected[value] = true } } return selected } func topologyStateFiltersFromQuery(r *http.Request) map[string]bool { selected := map[string]bool{} values := r.URL.Query()["state"] if len(values) == 0 { for _, key := range topologyStateFilterKeyOrder() { selected[key] = true } return selected } for _, value := range values { value = strings.TrimSpace(value) if value != "" { selected[value] = true } } return selected } func topologyFilterRows(links []topologyLinkCandidate, selected map[string]bool) []topologyFilterHTML { groups := topologyTypeFilterGroups(links, selected) out := []topologyFilterHTML{} for _, group := range groups { out = append(out, group.Filters...) } return out } func topologyFilterGroups(links []topologyLinkCandidate, selected map[string]bool, stateSelected map[string]bool) []topologyFilterGroupHTML { groups := topologyTypeFilterGroups(links, selected) groups = append(groups, topologyStateFilterGroup(links, stateSelected)) return groups } func topologyTypeFilterGroups(links []topologyLinkCandidate, selected map[string]bool) []topologyFilterGroupHTML { counts := map[string]int{} for _, link := range links { for _, key := range topologyFilterKeysForLink(link) { counts[key]++ } } groups := []topologyFilterGroupHTML{ {Title: "Нижний транспорт", InputName: "filter", Filters: topologyFilterRowsForGroup([]string{"observed:direct", "observed:private_lan", "observed:relay", "observed:unknown", "observed:suspicious"}, counts, selected)}, {Title: "Память и планы", InputName: "filter", Filters: topologyFilterRowsForGroup([]string{"route", "directory", "endpoint"}, counts, selected)}, {Title: "NAT и живучесть", InputName: "filter", Filters: topologyFilterRowsForGroup([]string{"rendezvous", "recovery"}, counts, selected)}, } return groups } func topologyStateFilterGroup(links []topologyLinkCandidate, selected map[string]bool) topologyFilterGroupHTML { counts := map[string]int{} for _, link := range links { counts[topologyStateFilterKey(link)]++ } return topologyFilterGroupHTML{ Title: "Состояние", InputName: "state", Filters: topologyFilterRowsForGroup(topologyStateFilterKeyOrder(), counts, selected), } } func topologyFilterRowsForGroup(keys []string, counts map[string]int, selected map[string]bool) []topologyFilterHTML { out := make([]topologyFilterHTML, 0, len(keys)) for _, key := range keys { out = append(out, topologyFilterHTML{Key: key, Label: topologyFilterLabel(key), Hint: topologyFilterHint(key), Checked: selected[key], Count: counts[key]}) } return out } func topologyFilterKeyOrder() []string { return []string{"observed:direct", "observed:private_lan", "observed:relay", "observed:unknown", "observed:suspicious", "route", "directory", "endpoint", "rendezvous", "recovery"} } func topologyStateFilterKeyOrder() []string { return []string{"state:reachable", "state:problem", "state:derived"} } func topologyStateFilterKey(link topologyLinkCandidate) string { if link.Kind != "observed" { return "state:derived" } status := strings.ToLower(strings.TrimSpace(link.Status)) if status == "reachable" || status == "healthy" || status == "ready" { return "state:reachable" } return "state:problem" } func topologyFilterKeysForLink(link topologyLinkCandidate) []string { if link.Kind != "observed" { return []string{link.Kind} } keys := []string{topologyObservedTransportFilterKey(link.TransportClass)} if link.Suspicious { keys = append(keys, "observed:suspicious") } return keys } func topologyObservedTransportFilterKey(class string) string { switch { case strings.Contains(class, "transport-relay"): return "observed:relay" case strings.Contains(class, "transport-private-lan"): return "observed:private_lan" case strings.Contains(class, "transport-direct"): return "observed:direct" default: return "observed:unknown" } } func topologyFilterLabel(key string) string { labels := map[string]string{ "observed:direct": "direct", "observed:private_lan": "private LAN", "observed:relay": "relay", "observed:unknown": "unknown", "observed:suspicious": "подозрительный direct", "route": "route plan", "directory": "peer directory", "endpoint": "endpoint", "rendezvous": "rendezvous", "recovery": "recovery seed", "state:reachable": "reachable", "state:problem": "degraded / waiting", "state:derived": "не факт", } return firstNonEmptyString(labels[key], key) } func topologyFilterHint(key string) string { hints := map[string]string{ "observed:direct": "публичный или прямой QUIC transport", "observed:private_lan": "реальная связь внутри одной LAN/подсети", "observed:relay": "факт через relay/rendezvous путь", "observed:unknown": "факт без полного transport metadata", "observed:suspicious": "входящий direct к NAT/outbound-only цели", "route": "рассчитанный hop маршрута", "directory": "запись, которую узел хранит о peer", "endpoint": "адрес-кандидат, по нему еще надо пробовать", "rendezvous": "аренда посредника для NAT", "recovery": "запасная точка восстановления", "state:reachable": "реально поднятая или успешно проверенная связь", "state:problem": "фактическая проверка есть, но связь не готова", "state:derived": "план, адрес или запись памяти, не проверенная связь", } return hints[key] } func topologyFactSummary(links []topologyLinkCandidate) (reachable int, problem int, planned int) { for _, link := range links { switch link.Kind { case "observed": status := strings.ToLower(strings.TrimSpace(link.Status)) if status == "reachable" || status == "healthy" || status == "ready" { reachable++ } else { problem++ } case "route": planned++ } } return reachable, problem, planned } func topologyInsights(links []topologyLinkCandidate, nodeByID map[string]topologyNodeHTML) []topologyInsightHTML { observedPairs := map[string]bool{} targetObserved := map[string]int{} plannedPairs := map[string]bool{} endpointPairs := map[string]bool{} rendezvousPairs := map[string]bool{} recoveryPairs := map[string]bool{} suspiciousDirect := 0 for _, link := range links { key := link.SourceID + "\x00" + link.TargetID switch link.Kind { case "observed": if strings.EqualFold(link.Status, "reachable") { observedPairs[key] = true targetObserved[link.TargetID]++ } if link.Suspicious { suspiciousDirect++ } case "route": plannedPairs[key] = true case "endpoint": endpointPairs[key] = true case "rendezvous": rendezvousPairs[key] = true case "recovery": recoveryPairs[key] = true } } unconfirmed := 0 for key := range plannedPairs { if !observedPairs[key] { unconfirmed++ } } missingEndpoint := 0 for key := range plannedPairs { if !endpointPairs[key] { missingEndpoint++ } } hotNodeID, hotCount := "", 0 for nodeID, count := range targetObserved { if count > hotCount { hotNodeID, hotCount = nodeID, count } } hotName := "нет" hotTone := "good" if hotNodeID != "" { hotName = firstNonEmptyString(nodeByID[hotNodeID].Name, hotNodeID) } if len(observedPairs) > 0 && hotCount*100/len(observedPairs) >= 50 { hotTone = "warn" } return []topologyInsightHTML{ {Title: "не подтверждено", Value: displayInt(unconfirmed), Tone: toneFromCount(unconfirmed), Sub: "route без reachable observation"}, {Title: "нет адреса", Value: displayInt(missingEndpoint), Tone: toneFromCount(missingEndpoint), Sub: "route без endpoint candidate"}, {Title: "сомнительный direct", Value: displayInt(suspiciousDirect), Tone: toneFromCount(suspiciousDirect), Sub: "входящий direct к NAT/outbound-only"}, {Title: "relay", Value: displayInt(len(rendezvousPairs)), Tone: toneFromCount(len(rendezvousPairs)), Sub: "rendezvous зависимость"}, {Title: "центр тяжести", Value: hotName, Tone: hotTone, Sub: displayInt(hotCount) + " входящих фактических связей"}, {Title: "recovery", Value: displayInt(len(recoveryPairs)), Tone: "warn", Sub: "запасные пути восстановления"}, } } func topologyLegendRows() []topologyLegendHTML { return []topologyLegendHTML{ {Name: "Transport", Tone: "good", Text: "transport fact: реальная проверенная QUIC связь узел-узел"}, {Name: "План", Tone: "muted", Text: "route: узлы должны иметь путь по synthetic mesh config"}, {Name: "Адрес", Tone: "muted", Text: "endpoint: куда можно пробовать QUIC, это еще не связь"}, {Name: "Relay", Tone: "warn", Text: "rendezvous: путь через посредника при NAT или недоступном direct"}, {Name: "Direct/NAT", Tone: "bad", Text: "красный direct к NAT/outbound-only требует проверки, нормален outbound или relay"}, {Name: "Recovery", Tone: "warn", Text: "seed: запасная точка восстановления связи"}, } } func topologyLinkKindOrder() []string { return []string{"observed", "route", "directory", "endpoint", "rendezvous", "recovery"} } func topologyLinkKindLabel(key string) string { labels := map[string]string{"observed": "transport факт", "route": "маршруты", "directory": "peer directory", "endpoint": "endpoint", "rendezvous": "rendezvous", "recovery": "recovery"} return firstNonEmptyString(labels[key], key) } func topologyLinkRows(links []topologyLinkCandidate, nodeByID map[string]topologyNodeHTML, filters map[string]bool, stateFilters map[string]bool) []topologyLinkHTML { sort.SliceStable(links, func(i, j int) bool { return links[i].Observed.After(links[j].Observed) }) out := []topologyLinkHTML{} pairCount := map[string]int{} for _, link := range links { if !topologyLinkPassesFilters(link, filters, stateFilters) { continue } source, okSource := nodeByID[link.SourceID] target, okTarget := nodeByID[link.TargetID] if !okSource || !okTarget { continue } pairKey := source.ID + "\x00" + target.ID pairCount[pairKey]++ path := topologyEdgePath(source.X, source.Y, target.X, target.Y, pairCount[pairKey]) tone := firstNonEmptyString(link.Tone, "muted") tooltip := source.Name + " -> " + target.Name + "\nтип: " + link.Label + "\n" + link.Tooltip out = append(out, topologyLinkHTML{ SourceName: source.Name, TargetName: target.Name, Kind: link.Kind, TransportClass: link.TransportClass, Label: link.Label, Tone: tone, Status: link.Status, Latency: link.Latency, Quality: link.Quality, Path: path, MarkerURL: "url(#arrow-" + tone + ")", Tooltip: tooltip, }) } return out } func topologyLinkPassesFilters(link topologyLinkCandidate, filters map[string]bool, stateFilters map[string]bool) bool { if !stateFilters[topologyStateFilterKey(link)] { return false } for _, key := range topologyFilterKeysForLink(link) { if filters[key] { return true } } return false } func topologyEdgePath(x1, y1, x2, y2 int, lane int) string { dx := float64(x2 - x1) dy := float64(y2 - y1) length := math.Hypot(dx, dy) if length == 0 { length = 1 } startOffset := 39.0 endOffset := 45.0 sx := float64(x1) + dx/length*startOffset sy := float64(y1) + dy/length*startOffset ex := float64(x2) - dx/length*endOffset ey := float64(y2) - dy/length*endOffset perpX := -dy / length perpY := dx / length laneOffset := float64((lane+1)/2) * 16 if lane%2 == 0 { laneOffset = -laneOffset } curve := math.Min(78, math.Max(26, length*0.14)) + laneOffset cx := (sx+ex)/2 + perpX*curve cy := (sy+ey)/2 + perpY*curve return "M " + displayInt(int(math.Round(sx))) + " " + displayInt(int(math.Round(sy))) + " Q " + displayInt(int(math.Round(cx))) + " " + displayInt(int(math.Round(cy))) + " " + displayInt(int(math.Round(ex))) + " " + displayInt(int(math.Round(ey))) } func webControlServiceFrom(serviceType string, desired []NodeWorkloadDesiredState, statuses []NodeWorkloadStatus) webControlServiceHTML { out := webControlServiceHTML{ServiceType: serviceType, DesiredState: "disabled", DesiredTone: "warn", RuntimeMode: "container", Version: "", ReportedState: "нет отчета", ReportedTone: "warn", ObservedAt: "никогда"} for _, item := range desired { if item.ServiceType == serviceType { out.DesiredState = firstNonEmptyString(item.DesiredState, "disabled") out.DesiredTone = statusTone(out.DesiredState) out.RuntimeMode = firstNonEmptyString(item.RuntimeMode, "container") if item.Version != nil { out.Version = strings.TrimSpace(*item.Version) } } } for _, status := range statuses { if status.ServiceType == serviceType { out.ReportedState = displayStatus(status.ReportedState) out.ReportedTone = statusTone(status.ReportedState) out.ObservedAt = consoleTime(status.ObservedAt) if status.Version != nil && out.Version == "" { out.Version = strings.TrimSpace(*status.Version) } break } } return out } func fabricLinkRows(input []MeshLinkObservation, limit int) []fabricLinkHTML { sort.SliceStable(input, func(i, j int) bool { return input[i].ObservedAt.After(input[j].ObservedAt) }) if limit > 0 && len(input) > limit { input = input[:limit] } out := make([]fabricLinkHTML, 0, len(input)) for _, item := range input { latency, quality := "нет", "нет" if item.LatencyMs != nil { latency = displayInt(*item.LatencyMs) + " ms" } if item.QualityScore != nil { quality = displayInt(*item.QualityScore) } metadata := meshObservationMetadata(item.Metadata) out = append(out, fabricLinkHTML{ Source: item.SourceNodeID, Target: item.TargetNodeID, Status: displayStatus(item.LinkStatus), Tone: statusTone(item.LinkStatus), Mode: firstNonEmptyString(metadata["transport_mode"], metadata["transport"], "unknown"), Observation: firstNonEmptyString(metadata["observation_type"], "mesh_probe"), Latency: latency, Quality: quality, ObservedAt: consoleTime(item.ObservedAt), }) } return out } func auditEventRows(input []ClusterAuditEvent) []auditEventHTML { out := make([]auditEventHTML, 0, len(input)) for _, event := range input { target := event.TargetType if event.TargetID != nil && strings.TrimSpace(*event.TargetID) != "" { target += " · " + strings.TrimSpace(*event.TargetID) } out = append(out, auditEventHTML{Type: displayStatus(event.EventType), Target: target, Tone: auditTone(event.EventType), CreatedAt: consoleTime(event.CreatedAt), Summary: auditSummary(event.Payload)}) } return out } func auditTone(eventType string) string { if strings.Contains(eventType, "blocked") || strings.Contains(eventType, "failed") { return "bad" } if strings.Contains(eventType, "triggered") || strings.Contains(eventType, "updated") || strings.Contains(eventType, "set") { return "good" } return "warn" } func auditSummary(raw json.RawMessage) string { if len(raw) == 0 { return "" } var payload map[string]any if json.Unmarshal(raw, &payload) != nil { return string(raw) } keys := make([]string, 0, len(payload)) for key := range payload { keys = append(keys, key) } sort.Strings(keys) if len(keys) > 4 { keys = keys[:4] } return strings.Join(keys, ", ") } func toneFromCount(count int) string { if count > 0 { return "warn" } return "good" } func formInt(r *http.Request, key string) int { value, _ := strconv.Atoi(strings.TrimSpace(r.FormValue(key))) return value } func formBoolPtr(r *http.Request, key string) *bool { value := strings.ToLower(strings.TrimSpace(r.FormValue(key))) out := value == "true" || value == "on" || value == "1" || value == "enabled" return &out } func formCSV(r *http.Request, key string) []string { raw := strings.NewReplacer("\r", ",", "\n", ",", ";", ",").Replace(r.FormValue(key)) out := []string{} for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out } func consoleTime(value time.Time) string { if value.IsZero() { return "не задано" } return value.UTC().Format("2006-01-02 15:04:05 UTC") } var farmOverviewHTMLTemplate = template.Must(template.New("farm-overview").Parse(consoleHTMLHead("Обзор фермы") + `
{{.Cluster.Slug}} · {{.Cluster.Status}} · обновлено {{.GeneratedAt}}
| {{.Name}}{{.ReportedVersion}} | {{.Freshness}} | {{.OperationalState}} |