package cluster import ( "bytes" "encoding/json" "html/template" "net/http" "sort" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/example/remote-access-platform/backend/internal/platform/httpx" ) type nodesHTMLPage struct { ClusterID string ActorUserID string GeneratedAt string Nav []consoleNavItem Summary nodesHTMLSummary Rows []nodesHTMLRow FragmentPath string } type adminHTMLPage struct { ActorUserID string GeneratedAt string Clusters []adminHTMLCluster } type adminHTMLCluster struct { ID string Name string Slug string Status string Region string NodeCount int FreshNodes int CurrentNodes int AttentionNodes int NodesURL string OverviewURL string UpdatesURL string TopologyURL string FabricURL string WebControlURL string AuditURL string Tone string } type nodesHTMLSummary struct { Total int Fresh int Current int NeedsReview int } type nodesHTMLRow struct { ID string Name string Group string Health string HealthTone string VersionState string VersionTone string ReportedVersion string RegistrationStatus string MembershipStatus string LastSeen string Freshness string FreshnessTone string OperationalState string OperationalTone string DetailsPath string } type nodeDetailsHTML struct { Node nodesHTMLRow Heartbeat nodeHeartbeatHTML UpdateStatus []nodeUpdateStatusHTML } type nodeHeartbeatHTML struct { Status string Tone string ObservedAt string Version string } type nodeUpdateStatusHTML struct { Product string Current string Target string Phase string Status string Tone string ObservedAt string ErrorMessage string } type updatesHTMLPage struct { ClusterID string ActorUserID string GeneratedAt string Nav []consoleNavItem FragmentPath string BulkCheckNowPath string Summary updatesHTMLSummary Releases []updateReleaseHTML Rows []updateNodeHTML } type updatesHTMLSummary struct { Nodes int Current int Outdated int NoStatus int Failed int } type updateReleaseHTML struct { Product string Version string Channel string Status string Tone string Artifacts int } type updateNodeHTML struct { NodeID string NodeName string Freshness string FreshTone string VersionState string VersionTone string Products []nodeUpdateStatusHTML Diagnosis string DiagnosisTone string RuntimeNote string CheckNowPath string ActionID string ActionText string ActionTone string } type updateRuntimeHTML struct { Reason string TriggerReason string WakeStatus string WakeError string LocalLaunchStatus string LocalLaunchError string DeliveryMode string SubscriptionStatus string TargetVersions string } type updateCheckNowHTML struct { NodeName string CheckNow bool Tone string Reason string Generation string DeliveryMode string SubscriptionStatus string UpdateService string UpdateServiceStatus string UpdateServiceFallback string TargetVersions []string GeneratedAt string } type updateCheckNowAllHTML struct { GeneratedAt string Total int Ready int NoPolicy int Rows []updateCheckNowHTML } func (m *Module) renderNodesHTML(w http.ResponseWriter, r *http.Request) { page, err := m.nodesHTMLPageModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := nodesHTMLPageTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render nodes page") } } func (m *Module) renderAdminHTML(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(r.URL.Query().Get("actor_user_id")) == "" { http.Redirect(w, r, "/api/v1/auth/ui/login", http.StatusSeeOther) return } page, err := m.adminHTMLPageModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := adminHTMLPageTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render admin page") } } func (m *Module) renderNodesHTMLFragment(w http.ResponseWriter, r *http.Request) { page, err := m.nodesHTMLPageModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := nodesHTMLFragmentTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render nodes fragment") } } func (m *Module) renderNodeDetailsHTML(w http.ResponseWriter, r *http.Request) { page, err := m.nodeDetailsHTMLModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := nodeDetailsHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render node details") } } func (m *Module) renderUpdatesHTML(w http.ResponseWriter, r *http.Request) { page, err := m.updatesHTMLPageModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := updatesHTMLPageTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render updates page") } } func (m *Module) renderUpdatesHTMLFragment(w http.ResponseWriter, r *http.Request) { page, err := m.updatesHTMLPageModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := updatesHTMLFragmentTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render updates fragment") } } func (m *Module) renderUpdateCheckNowHTML(w http.ResponseWriter, r *http.Request) { page, err := m.updateCheckNowHTMLModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := updateCheckNowHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render update check-now") } } func (m *Module) renderUpdateCheckNowAllHTML(w http.ResponseWriter, r *http.Request) { page, err := m.updateCheckNowAllHTMLModel(r) if writeServiceError(w, err) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := updateCheckNowAllHTMLTemplate.Execute(w, page); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "render update check-now all") } } func (m *Module) renderHTMXLiteJS(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") _, _ = w.Write([]byte(htmxLiteJS)) } func (m *Module) adminHTMLPageModel(r *http.Request) (adminHTMLPage, error) { actorUserID := strings.TrimSpace(r.URL.Query().Get("actor_user_id")) clusters, err := m.service.ListClusters(r.Context(), actorUserID) if err != nil { return adminHTMLPage{}, err } now := time.Now().UTC() page := adminHTMLPage{ ActorUserID: actorUserID, GeneratedAt: now.Format("2006-01-02 15:04:05 UTC"), Clusters: make([]adminHTMLCluster, 0, len(clusters)), } query := "" if actorUserID != "" { query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID) } for _, cluster := range clusters { nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, cluster.ID) if err != nil { return adminHTMLPage{}, err } item := adminHTMLCluster{ ID: cluster.ID, Name: cluster.Name, Slug: cluster.Slug, Status: displayStatus(cluster.Status), Region: "регион не задан", OverviewURL: "/api/v1/clusters/" + cluster.ID + "/ui/overview" + query, NodesURL: "/api/v1/clusters/" + cluster.ID + "/ui/nodes" + query, UpdatesURL: "/api/v1/clusters/" + cluster.ID + "/ui/updates" + query, TopologyURL: "/api/v1/clusters/" + cluster.ID + "/ui/topology" + query, FabricURL: "/api/v1/clusters/" + cluster.ID + "/ui/fabric" + query, WebControlURL: "/api/v1/clusters/" + cluster.ID + "/ui/web-control" + query, AuditURL: "/api/v1/clusters/" + cluster.ID + "/ui/audit" + query, Tone: statusTone(cluster.Status), } if cluster.Region != nil && strings.TrimSpace(*cluster.Region) != "" { item.Region = strings.TrimSpace(*cluster.Region) } item.NodeCount = len(nodes) for _, node := range nodes { row := nodesHTMLRowFromNode(node, now) if row.FreshnessTone == "good" { item.FreshNodes++ } if row.VersionTone == "good" { item.CurrentNodes++ } if row.OperationalTone != "good" { item.AttentionNodes++ } } if item.AttentionNodes > 0 && item.Tone == "good" { item.Tone = "warn" } page.Clusters = append(page.Clusters, item) } sort.SliceStable(page.Clusters, func(i, j int) bool { if page.Clusters[i].Tone != page.Clusters[j].Tone { return toneRank(page.Clusters[i].Tone) < toneRank(page.Clusters[j].Tone) } return strings.ToLower(page.Clusters[i].Name) < strings.ToLower(page.Clusters[j].Name) }) return page, nil } func (m *Module) updatesHTMLPageModel(r *http.Request) (updatesHTMLPage, error) { clusterID := chi.URLParam(r, "clusterID") actorUserID := strings.TrimSpace(r.URL.Query().Get("actor_user_id")) nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID) if err != nil { return updatesHTMLPage{}, err } releases, err := m.service.ListReleaseVersions(r.Context(), actorUserID, clusterID, "", "") if err != nil { return updatesHTMLPage{}, err } now := time.Now().UTC() query := "" if actorUserID != "" { query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID) } page := updatesHTMLPage{ ClusterID: clusterID, ActorUserID: actorUserID, GeneratedAt: now.Format("2006-01-02 15:04:05 UTC"), Nav: consoleNav(clusterID, actorUserID, "updates"), FragmentPath: "/api/v1/clusters/" + clusterID + "/ui/updates/fragment" + query, BulkCheckNowPath: "/api/v1/clusters/" + clusterID + "/ui/updates/check-now" + query, Summary: updatesHTMLSummary{Nodes: len(nodes)}, Releases: releaseHTMLRows(releases), Rows: make([]updateNodeHTML, 0, len(nodes)), } for _, node := range nodes { row := nodesHTMLRowFromNode(node, now) checkNowPath := updateCheckNowHTMLPath(clusterID, node.ID, actorUserID) item := updateNodeHTML{ NodeID: node.ID, NodeName: node.Name, Freshness: row.Freshness, FreshTone: row.FreshnessTone, VersionState: row.VersionState, VersionTone: row.VersionTone, CheckNowPath: checkNowPath, ActionID: "update-action-" + node.ID, ActionText: "Сформировать сигнал", ActionTone: "good", } if row.VersionTone == "good" { page.Summary.Current++ } else { page.Summary.Outdated++ } statuses, err := m.service.ListNodeUpdateStatuses(r.Context(), actorUserID, clusterID, node.ID, 6) if err != nil { return updatesHTMLPage{}, err } if len(statuses) == 0 { page.Summary.NoStatus++ } for _, status := range statuses { statusRow := nodeUpdateStatusHTMLFromStatus(status) if statusRow.Tone == "bad" { page.Summary.Failed++ } item.Products = append(item.Products, statusRow) } var latestHeartbeat *NodeHeartbeat if heartbeats, err := m.service.ListNodeHeartbeats(r.Context(), actorUserID, clusterID, node.ID, 1); err == nil && len(heartbeats) > 0 { latestHeartbeat = &heartbeats[0] } item.Diagnosis, item.DiagnosisTone, item.RuntimeNote = updateNodeDiagnosisHTML(row, statuses, latestHeartbeat, now) page.Rows = append(page.Rows, item) } sort.SliceStable(page.Rows, func(i, j int) bool { if page.Rows[i].VersionTone != page.Rows[j].VersionTone { return toneRank(page.Rows[i].VersionTone) < toneRank(page.Rows[j].VersionTone) } return strings.ToLower(page.Rows[i].NodeName) < strings.ToLower(page.Rows[j].NodeName) }) return page, nil } func (m *Module) updateCheckNowAllHTMLModel(r *http.Request) (updateCheckNowAllHTML, error) { clusterID := chi.URLParam(r, "clusterID") actorUserID := strings.TrimSpace(r.URL.Query().Get("actor_user_id")) nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID) if err != nil { return updateCheckNowAllHTML{}, err } generatedAt := time.Now().UTC().Format("2006-01-02 15:04:05 UTC") out := updateCheckNowAllHTML{ GeneratedAt: generatedAt, Total: len(nodes), Rows: make([]updateCheckNowHTML, 0, len(nodes)), } for _, node := range nodes { hint, err := m.service.TriggerNodeUpdateHint(r.Context(), actorUserID, clusterID, node.ID) if err != nil { return updateCheckNowAllHTML{}, err } row := updateCheckNowHTMLFromHint(node.Name, hint, generatedAt) if row.CheckNow { out.Ready++ } else { out.NoPolicy++ } out.Rows = append(out.Rows, row) } sort.SliceStable(out.Rows, func(i, j int) bool { if out.Rows[i].Tone != out.Rows[j].Tone { return toneRank(out.Rows[i].Tone) < toneRank(out.Rows[j].Tone) } return strings.ToLower(out.Rows[i].NodeName) < strings.ToLower(out.Rows[j].NodeName) }) return out, nil } func (m *Module) updateCheckNowHTMLModel(r *http.Request) (updateCheckNowHTML, error) { clusterID := chi.URLParam(r, "clusterID") nodeID := chi.URLParam(r, "nodeID") actorUserID := strings.TrimSpace(r.URL.Query().Get("actor_user_id")) nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID) if err != nil { return updateCheckNowHTML{}, err } nodeName := nodeID found := false for _, node := range nodes { if strings.TrimSpace(node.ID) == strings.TrimSpace(nodeID) { nodeName = node.Name found = true break } } if !found { return updateCheckNowHTML{}, ErrInvalidPayload } hint, err := m.service.TriggerNodeUpdateHint(r.Context(), actorUserID, clusterID, nodeID) if err != nil { return updateCheckNowHTML{}, err } return updateCheckNowHTMLFromHint(nodeName, hint, time.Now().UTC().Format("2006-01-02 15:04:05 UTC")), nil } func updateCheckNowHTMLFromHint(nodeName string, hint NodeUpdateHint, generatedAt string) updateCheckNowHTML { out := updateCheckNowHTML{ NodeName: nodeName, CheckNow: hint.CheckNow, Tone: "warn", Reason: displayStatus(hint.Reason), Generation: firstNonEmptyString(hint.Generation, "нет активного поколения"), DeliveryMode: firstNonEmptyString(displayStatus(hint.DeliveryMode), "не задано"), SubscriptionStatus: firstNonEmptyString(displayStatus(hint.SubscriptionStatus), "не задано"), TargetVersions: updateHintTargetHTMLRows(hint.TargetVersions), GeneratedAt: generatedAt, } if hint.CheckNow { out.Tone = "good" } if hint.UpdateService != nil { out.UpdateService = firstNonEmptyString(hint.UpdateService.NodeName, hint.UpdateService.NodeID) out.UpdateServiceStatus = displayStatus(hint.UpdateService.Status) } if out.UpdateService == "" { out.UpdateService = "не выбран" out.UpdateServiceStatus = "ожидает кандидата" } if len(hint.UpdateServiceCandidates) > 1 { out.UpdateServiceFallback = "резервов: " + strings.TrimSpace(displayInt(len(hint.UpdateServiceCandidates)-1)) } else { out.UpdateServiceFallback = "резервов нет" } return out } func (m *Module) nodesHTMLPageModel(r *http.Request) (nodesHTMLPage, error) { clusterID := chi.URLParam(r, "clusterID") actorUserID := strings.TrimSpace(r.URL.Query().Get("actor_user_id")) nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID) if err != nil { return nodesHTMLPage{}, err } now := time.Now().UTC() rows := make([]nodesHTMLRow, 0, len(nodes)) summary := nodesHTMLSummary{Total: len(nodes)} for _, node := range nodes { row := nodesHTMLRowFromNode(node, now) row.DetailsPath = nodeDetailsHTMLPath(clusterID, node.ID, actorUserID) if row.FreshnessTone == "good" { summary.Fresh++ } if row.VersionTone == "good" { summary.Current++ } if row.OperationalTone != "good" { summary.NeedsReview++ } rows = append(rows, row) } sort.SliceStable(rows, func(i, j int) bool { if rows[i].OperationalTone != rows[j].OperationalTone { return toneRank(rows[i].OperationalTone) < toneRank(rows[j].OperationalTone) } return strings.ToLower(rows[i].Name) < strings.ToLower(rows[j].Name) }) query := "" if actorUserID != "" { query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID) } return nodesHTMLPage{ ClusterID: clusterID, ActorUserID: actorUserID, GeneratedAt: now.Format("2006-01-02 15:04:05 UTC"), Nav: consoleNav(clusterID, actorUserID, "nodes"), Summary: summary, Rows: rows, FragmentPath: "/api/v1/clusters/" + clusterID + "/ui/nodes/fragment" + query, }, nil } func (m *Module) nodeDetailsHTMLModel(r *http.Request) (nodeDetailsHTML, error) { clusterID := chi.URLParam(r, "clusterID") nodeID := chi.URLParam(r, "nodeID") actorUserID := strings.TrimSpace(r.URL.Query().Get("actor_user_id")) nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID) if err != nil { return nodeDetailsHTML{}, err } var node *ClusterNode for i := range nodes { if nodes[i].ID == nodeID { node = &nodes[i] break } } if node == nil { return nodeDetailsHTML{}, ErrInvalidPayload } now := time.Now().UTC() row := nodesHTMLRowFromNode(*node, now) row.DetailsPath = nodeDetailsHTMLPath(clusterID, nodeID, actorUserID) out := nodeDetailsHTML{Node: row} if heartbeats, err := m.service.ListNodeHeartbeats(r.Context(), actorUserID, clusterID, nodeID, 1); err == nil && len(heartbeats) > 0 { out.Heartbeat = nodeHeartbeatHTMLFromHeartbeat(heartbeats[0]) } statuses, err := m.service.ListNodeUpdateStatuses(r.Context(), actorUserID, clusterID, nodeID, 8) if err != nil { return nodeDetailsHTML{}, err } out.UpdateStatus = make([]nodeUpdateStatusHTML, 0, len(statuses)) for _, status := range statuses { out.UpdateStatus = append(out.UpdateStatus, nodeUpdateStatusHTMLFromStatus(status)) } return out, nil } func nodesHTMLRowFromNode(node ClusterNode, now time.Time) nodesHTMLRow { freshness, freshnessTone := nodeHTMLFreshness(node.LastSeenAt, now) healthTone := statusTone(node.HealthStatus) versionTone := statusTone(node.VersionState) reportedVersion := "нет отчета" if node.ReportedVersion != nil && strings.TrimSpace(*node.ReportedVersion) != "" { reportedVersion = strings.TrimSpace(*node.ReportedVersion) } group := "без группы" if node.NodeGroupName != nil && strings.TrimSpace(*node.NodeGroupName) != "" { group = strings.TrimSpace(*node.NodeGroupName) } operational, operationalTone := nodeHTMLOperationalState(node, freshnessTone, versionTone) return nodesHTMLRow{ ID: node.ID, Name: node.Name, Group: group, Health: displayStatus(node.HealthStatus), HealthTone: healthTone, VersionState: displayStatus(node.VersionState), VersionTone: versionTone, ReportedVersion: reportedVersion, RegistrationStatus: displayStatus(node.RegistrationStatus), MembershipStatus: displayStatus(node.MembershipStatus), LastSeen: nodeHTMLLastSeen(node.LastSeenAt), Freshness: freshness, FreshnessTone: freshnessTone, OperationalState: operational, OperationalTone: operationalTone, } } func nodeDetailsHTMLPath(clusterID, nodeID, actorUserID string) string { query := "" if strings.TrimSpace(actorUserID) != "" { query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID) } return "/api/v1/clusters/" + clusterID + "/ui/nodes/" + nodeID + "/details" + query } func updateCheckNowHTMLPath(clusterID, nodeID, actorUserID string) string { query := "" if strings.TrimSpace(actorUserID) != "" { query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID) } return "/api/v1/clusters/" + clusterID + "/ui/updates/" + nodeID + "/check-now" + query } func nodeHeartbeatHTMLFromHeartbeat(item NodeHeartbeat) nodeHeartbeatHTML { version := "нет отчета" if item.ReportedVersion != nil && strings.TrimSpace(*item.ReportedVersion) != "" { version = strings.TrimSpace(*item.ReportedVersion) } return nodeHeartbeatHTML{ Status: displayStatus(item.HealthStatus), Tone: statusTone(item.HealthStatus), ObservedAt: item.ObservedAt.UTC().Format("2006-01-02 15:04:05 UTC"), Version: version, } } func nodeUpdateStatusHTMLFromStatus(item NodeUpdateStatus) nodeUpdateStatusHTML { errText := "" if item.ErrorMessage != nil { errText = strings.TrimSpace(*item.ErrorMessage) } current := strings.TrimSpace(item.CurrentVersion) if current == "" { current = "неизвестно" } target := strings.TrimSpace(item.TargetVersion) if target == "" { target = "не задано" } return nodeUpdateStatusHTML{ Product: item.Product, Current: current, Target: target, Phase: displayStatus(item.Phase), Status: displayStatus(item.Status), Tone: updateStatusTone(item), ObservedAt: item.ObservedAt.UTC().Format("2006-01-02 15:04:05 UTC"), ErrorMessage: errText, } } func updateStatusTone(item NodeUpdateStatus) string { status := strings.ToLower(strings.TrimSpace(item.Status)) phase := strings.ToLower(strings.TrimSpace(item.Phase)) if status == "failed" || strings.TrimSpace(stringValue(item.ErrorMessage)) != "" { return "bad" } if status == "succeeded" || status == "noop" || status == "already_current" { return "good" } if phase == "download" || phase == "apply" || phase == "health_check" { return "warn" } return statusTone(status) } func updateNodeDiagnosisHTML(row nodesHTMLRow, statuses []NodeUpdateStatus, heartbeat *NodeHeartbeat, now time.Time) (string, string, string) { runtime := updateRuntimeHTMLFromHeartbeat(heartbeat) runtimeNote := updateRuntimeNoteHTML(runtime) if row.FreshnessTone == "bad" { if row.VersionTone == "good" { return "узел не присылает отчет; версия по последнему отчету была актуальна", "warn", runtimeNote } return "узел не на связи: обновление выполнится после возврата в fabric", "bad", runtimeNote } if row.VersionTone == "good" { return "версия актуальна", "good", runtimeNote } if updateRuntimeIndicatesObservedTrigger(runtime) && updateStatusesOlderThanHeartbeat(statuses, heartbeat, 2*time.Minute) { return "сигнал получен, но update executor не отчитался о download/apply", "bad", runtimeNote } if latestUpdateStatusFailed(statuses) { return "последняя попытка завершилась ошибкой; нужен повторный сигнал и контроль rollback", "bad", runtimeNote } if len(statuses) == 0 { return "нет отчета от executor; формируем сигнал через fabric", "warn", runtimeNote } if updateStatusesOlderThanHeartbeat(statuses, heartbeat, 30*time.Minute) { return "отчет узла свежий, но статус обновления старый; executor нужно разбудить сигналом", "warn", runtimeNote } return "ожидает применения целевой версии через fabric", "warn", runtimeNote } func updateRuntimeHTMLFromHeartbeat(heartbeat *NodeHeartbeat) updateRuntimeHTML { if heartbeat == nil || len(heartbeat.Metadata) == 0 { return updateRuntimeHTML{} } var envelope struct { UpdateRuntime map[string]any `json:"update_runtime"` } if err := json.Unmarshal(heartbeat.Metadata, &envelope); err != nil || len(envelope.UpdateRuntime) == 0 { return updateRuntimeHTML{} } return updateRuntimeHTML{ Reason: metadataString(envelope.UpdateRuntime, "reason"), TriggerReason: metadataString(envelope.UpdateRuntime, "trigger_reason"), WakeStatus: metadataString(envelope.UpdateRuntime, "trigger_wake_status"), WakeError: metadataString(envelope.UpdateRuntime, "trigger_wake_error"), LocalLaunchStatus: metadataString(envelope.UpdateRuntime, "trigger_local_launch_status"), LocalLaunchError: metadataString(envelope.UpdateRuntime, "trigger_local_launch_error"), DeliveryMode: metadataString(envelope.UpdateRuntime, "trigger_delivery_mode"), SubscriptionStatus: metadataString(envelope.UpdateRuntime, "trigger_subscription_status"), TargetVersions: firstNonEmptyString( updateRuntimeTargetsHTML(envelope.UpdateRuntime["target_versions"]), updateRuntimeTargetsHTML(envelope.UpdateRuntime["rescue_intent_target_versions"]), ), } } func updateRuntimeNoteHTML(runtime updateRuntimeHTML) string { parts := make([]string, 0, 5) if runtime.Reason != "" { parts = append(parts, "runtime: "+runtime.Reason) } if runtime.TriggerReason != "" { parts = append(parts, "trigger: "+runtime.TriggerReason) } if runtime.WakeStatus != "" { parts = append(parts, "wake: "+runtime.WakeStatus) } if runtime.LocalLaunchStatus != "" { parts = append(parts, "local launch: "+runtime.LocalLaunchStatus) } if runtime.SubscriptionStatus != "" { parts = append(parts, "subscription: "+runtime.SubscriptionStatus) } if runtime.TargetVersions != "" { parts = append(parts, "target: "+runtime.TargetVersions) } if runtime.WakeError != "" { parts = append(parts, "error: "+runtime.WakeError) } if runtime.LocalLaunchError != "" { parts = append(parts, "local error: "+runtime.LocalLaunchError) } return strings.Join(parts, " · ") } func updateRuntimeIndicatesObservedTrigger(runtime updateRuntimeHTML) bool { joined := strings.ToLower(strings.Join([]string{ runtime.Reason, runtime.TriggerReason, runtime.WakeStatus, runtime.LocalLaunchStatus, runtime.SubscriptionStatus, }, " ")) return strings.Contains(joined, "trigger") || strings.Contains(joined, "started") || strings.Contains(joined, "subscribed") } func updateStatusesOlderThanHeartbeat(statuses []NodeUpdateStatus, heartbeat *NodeHeartbeat, tolerance time.Duration) bool { if heartbeat == nil || len(statuses) == 0 { return false } latest := statuses[0].ObservedAt.UTC() for _, status := range statuses[1:] { if status.ObservedAt.UTC().After(latest) { latest = status.ObservedAt.UTC() } } return heartbeat.ObservedAt.UTC().After(latest.Add(tolerance)) } func latestUpdateStatusFailed(statuses []NodeUpdateStatus) bool { for _, status := range statuses { if updateStatusTone(status) == "bad" { return true } } return false } func metadataString(values map[string]any, key string) string { value, ok := values[key] if !ok || value == nil { return "" } switch typed := value.(type) { case string: return strings.TrimSpace(typed) case bool: if typed { return "true" } return "false" case float64: return strconv.FormatFloat(typed, 'f', -1, 64) default: bytes, err := json.Marshal(typed) if err != nil { return "" } return strings.Trim(string(bytes), `"`) } } func updateRuntimeTargetsHTML(value any) string { targets, ok := value.(map[string]any) if !ok || len(targets) == 0 { return "" } parts := make([]string, 0, len(targets)) for product, version := range targets { product = strings.TrimSpace(product) versionText := strings.TrimSpace(updateRuntimeMetadataAnyString(version)) if product == "" || versionText == "" { continue } parts = append(parts, product+" "+versionText) } sort.Strings(parts) return strings.Join(parts, ", ") } func updateRuntimeMetadataAnyString(value any) string { switch typed := value.(type) { case string: return typed case bool: if typed { return "true" } return "false" case float64: return strconv.FormatFloat(typed, 'f', -1, 64) default: bytes, err := json.Marshal(typed) if err != nil { return "" } return strings.Trim(string(bytes), `"`) } } func releaseHTMLRows(releases []ReleaseVersion) []updateReleaseHTML { out := make([]updateReleaseHTML, 0, len(releases)) for _, release := range releases { out = append(out, updateReleaseHTML{ Product: release.Product, Version: release.Version, Channel: release.Channel, Status: displayStatus(release.Status), Tone: statusTone(release.Status), Artifacts: len(release.Artifacts), }) } sort.SliceStable(out, func(i, j int) bool { if out[i].Product != out[j].Product { return out[i].Product < out[j].Product } return out[i].Version > out[j].Version }) return out } func updateHintTargetHTMLRows(targets map[string]string) []string { out := make([]string, 0, len(targets)) for product, version := range targets { product = strings.TrimSpace(product) version = strings.TrimSpace(version) if product == "" || version == "" { continue } out = append(out, product+" -> "+version) } sort.Strings(out) return out } func displayInt(value int) string { if value <= 0 { return "0" } digits := make([]byte, 0, 8) for value > 0 { digits = append(digits, byte('0'+value%10)) value /= 10 } for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { digits[i], digits[j] = digits[j], digits[i] } return string(digits) } func stringValue(value *string) string { if value == nil { return "" } return *value } func nodeHTMLFreshness(lastSeen *time.Time, now time.Time) (string, string) { if lastSeen == nil { return "отчет узла не поступал", "bad" } age := now.Sub(lastSeen.UTC()) if age < 0 { age = 0 } switch { case age <= 2*time.Minute: return "отчет узла свежий", "good" case age <= 15*time.Minute: return "отчет узла задержан", "warn" default: return "отчет узла устарел", "bad" } } func nodeHTMLLastSeen(lastSeen *time.Time) string { if lastSeen == nil { return "никогда" } return lastSeen.UTC().Format("2006-01-02 15:04:05 UTC") } func nodeHTMLOperationalState(node ClusterNode, freshnessTone, versionTone string) (string, string) { if freshnessTone == "bad" { return "нет свежего отчета узла", "bad" } if !strings.EqualFold(node.HealthStatus, "healthy") { return "требует проверки", "bad" } if versionTone != "good" { return "ожидает синхронизацию", "warn" } return "рабочий", "good" } func statusTone(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "healthy", "current", "active", "approved", "enabled", "running": return "good" case "outdated", "pending", "stale", "degraded", "no_policy": return "warn" case "": return "muted" default: return "bad" } } func toneRank(tone string) int { switch tone { case "bad": return 0 case "warn": return 1 case "muted": return 2 default: return 3 } } func displayStatus(status string) string { status = strings.TrimSpace(status) if status == "" { return "не задано" } return strings.ReplaceAll(status, "_", " ") } var nodesHTMLPageTemplate = template.Must(template.New("nodes-page").Parse(`
обновлено {{.GeneratedAt}}
| Узел | Состояние | Отчет узла | Версия | Регистрация | Последний сигнал |
|---|---|---|---|---|---|
| {{.Name}}{{.Group}} | {{.OperationalState}}{{.Health}} | {{.Freshness}} | {{.VersionState}}{{.ReportedVersion}} | {{.RegistrationStatus}}{{.MembershipStatus}} | {{.LastSeen}} |
| узлы не найдены | |||||
обновлено {{.GeneratedAt}}
| Узел | Связь | Версия | Последние обновления | Диагноз | Сигнал |
|---|---|---|---|---|---|
| {{.NodeName}} | {{.Freshness}} | {{.VersionState}} |
{{range .Products}}
{{.Product}}: {{.Phase}}/{{.Status}} · {{.Current}} -> {{.Target}}
{{else}}
нет отчетов обновления
{{end}}
|
{{.Diagnosis}}
{{if .RuntimeNote}}{{.RuntimeNote}}{{end}}
|
|
| узлы не найдены | |||||
обновлено {{.GeneratedAt}}