Files
rdp-proxy/backend/internal/modules/cluster/module_nodes_html.go
T
m 20d361a886
build / backend (push) Has been cancelled
build / node-agent (push) Has been cancelled
build / worker (push) Has been cancelled
рабочий вариант, но скороть 10 МБит
2026-05-22 21:46:49 +03:00

1456 lines
53 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Узлы фермы</title>
<script src="/api/v1/ui/htmx-lite.js" defer></script>
<style>
:root { color: #172019; background: #eef0ea; font-family: system-ui, "Segoe UI", sans-serif; }
body { margin: 0; }
main { max-width: 1320px; margin: 0 auto; padding: 18px; }
header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; margin-bottom: 12px; }
h1 { margin: 0; font-size: 1.35rem; letter-spacing: 0; }
.muted { color: #667064; font-size: .86rem; }
.summary { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 8px; margin-bottom: 10px; }
.metric, .panel { border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; }
.metric { padding: 10px; }
.metric span { display: block; color: #667064; font-size: .78rem; font-weight: 700; }
.metric strong { display: block; margin-top: 4px; font-size: 1.35rem; }
.panel { overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #1a271b1f; text-align: left; vertical-align: top; padding: 8px; font-size: .86rem; }
th { color: #667064; background: #f8f7ef; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; }
tr:last-child td { border-bottom: 0; }
.node { font-weight: 800; }
.sub { color: #667064; display: block; margin-top: 2px; font-size: .76rem; }
.pill { display: inline-flex; width: fit-content; border-radius: 8px; padding: .22rem .48rem; font-size: .74rem; font-weight: 800; background: #36556c1a; color: #36556c; }
.good { color: #236c4a; background: #2f6f4f1f; }
.warn { color: #9a5b1c; background: #b86f2324; }
.bad { color: #a64235; background: #b144341f; }
.mutedPill { color: #667064; background: #17201912; }
.nav { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.nav a { padding: .48rem .68rem; border: 1px solid #1a271b22; border-radius: 8px; color: #172019; text-decoration: none; font-weight: 800; background: #fffdf5; }
.nav a.active { background: #2f6f4f; color: white; }
.toolbar { display: flex; justify-content: flex-end; gap: 8px; }
button { min-height: 34px; border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; color: #172019; padding: .42rem .7rem; cursor: pointer; }
.details { margin-top: 8px; border: 1px solid #1a271b1f; border-radius: 8px; background: #ffffff8a; padding: 10px; }
.detailsGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 8px; margin-top: 8px; }
.detailBox { border: 1px solid #1a271b1f; border-radius: 8px; background: #fffdf5; padding: 8px; }
.detailBox span { color: #667064; display: block; font-size: .72rem; font-weight: 800; }
.detailBox strong { display: block; margin-top: 3px; overflow-wrap: anywhere; }
@media (max-width: 820px) {
header, .summary { grid-template-columns: 1fr; }
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
main { padding: 10px; }
}
</style>
</head>
<body>
<main>
<nav class="nav">{{range .Nav}}<a class="{{if .Active}}active{{end}}" href="{{.URL}}">{{.Label}}</a>{{end}}</nav>
<header>
<div>
<h1>Узлы фермы</h1>
<div class="muted">серверное представление, HTML5 + HTMX, расчеты на контролере</div>
</div>
<div class="toolbar">
<button type="button" hx-get="{{.FragmentPath}}" hx-target="#nodes-fragment" hx-swap="outerHTML">Обновить</button>
</div>
</header>
{{template "fragment" .}}
</main>
</body>
</html>`))
var adminHTMLPageTemplate = template.Must(template.New("admin-page").Parse(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Панель фермы</title>
<style>
:root { color: #172019; background: #eef0ea; font-family: system-ui, "Segoe UI", sans-serif; }
body { margin: 0; }
main { max-width: 1280px; margin: 0 auto; padding: 18px; }
header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; margin-bottom: 14px; }
h1 { margin: 0; font-size: 1.55rem; letter-spacing: 0; }
.muted { color: #667064; font-size: .86rem; }
.identity { border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; padding: 10px; min-width: 260px; }
.identity span { display: block; color: #667064; font-size: .72rem; font-weight: 800; }
.identity strong { display: block; margin-top: 3px; overflow-wrap: anywhere; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 10px; }
.cluster { display: grid; gap: 10px; border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; padding: 12px; color: inherit; }
.head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: start; }
.name { font-weight: 850; font-size: 1.02rem; }
.sub { color: #667064; display: block; margin-top: 2px; font-size: .78rem; overflow-wrap: anywhere; }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.metric { border: 1px solid #1a271b1f; border-radius: 8px; background: #ffffff80; padding: 8px; }
.metric span { display: block; color: #667064; font-size: .68rem; font-weight: 800; }
.metric strong { display: block; margin-top: 3px; font-size: 1.1rem; }
.pill { display: inline-flex; width: fit-content; border-radius: 8px; padding: .22rem .48rem; font-size: .74rem; font-weight: 800; background: #36556c1a; color: #36556c; }
.good { color: #236c4a; background: #2f6f4f1f; }
.warn { color: #9a5b1c; background: #b86f2324; }
.bad { color: #a64235; background: #b144341f; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; }
.actions a { border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; color: #172019; padding: .42rem .58rem; text-decoration: none; font-size: .82rem; font-weight: 800; }
.actions a.primary { background: #2f6f4f; color: white; border-color: #2f6f4f; }
@media (max-width: 720px) { main { padding: 10px; } header { grid-template-columns: 1fr; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Панель управления фермой</h1>
<div class="muted">идентификация выполнена, ниже доступны все рабочие экраны консоли</div>
</div>
<div class="identity"><span>оператор</span><strong>{{.ActorUserID}}</strong></div>
</header>
<section class="grid">
{{range .Clusters}}
<article class="cluster">
<div class="head">
<div>
<div class="name">{{.Name}}</div>
<span class="sub">{{.Slug}} · {{.Region}}</span>
</div>
<span class="pill {{.Tone}}">{{.Status}}</span>
</div>
<div class="metrics">
<div class="metric"><span>узлы</span><strong>{{.NodeCount}}</strong></div>
<div class="metric"><span>свежий отчет</span><strong>{{.FreshNodes}}</strong></div>
<div class="metric"><span>версия</span><strong>{{.CurrentNodes}}</strong></div>
<div class="metric"><span>проверить</span><strong>{{.AttentionNodes}}</strong></div>
</div>
<div class="actions">
<a class="primary" href="{{.OverviewURL}}">Обзор</a>
<a href="{{.NodesURL}}">Узлы</a>
<a href="{{.UpdatesURL}}">Обновления</a>
<a href="{{.TopologyURL}}">Топология</a>
<a href="{{.FabricURL}}">Fabric</a>
<a href="{{.WebControlURL}}">Веб-контроль</a>
<a href="{{.AuditURL}}">Аудит</a>
</div>
</article>
{{else}}
<div class="cluster">кластеры не найдены</div>
{{end}}
</section>
<p class="muted">обновлено {{.GeneratedAt}}</p>
</main>
</body>
</html>`))
var nodesHTMLFragmentTemplate = template.Must(nodesHTMLPageTemplate.New("fragment-only").Parse(`{{template "fragment" .}}`))
var nodeDetailsHTMLTemplate = template.Must(template.New("node-details").Parse(`
<div class="details">
<div class="head">
<div>
<div class="node">{{.Node.Name}}</div>
<span class="sub">{{.Node.OperationalState}} · {{.Node.LastSeen}}</span>
</div>
<span class="pill {{.Node.OperationalTone}}">{{.Node.OperationalState}}</span>
</div>
<div class="detailsGrid">
<div class="detailBox"><span>отчет узла</span><strong>{{.Heartbeat.Status}}</strong><span>источник: heartbeat · {{.Heartbeat.ObservedAt}}</span></div>
<div class="detailBox"><span>версия из отчета</span><strong>{{.Heartbeat.Version}}</strong></div>
<div class="detailBox"><span>состояние версии</span><strong>{{.Node.VersionState}}</strong><span>{{.Node.ReportedVersion}}</span></div>
<div class="detailBox"><span>членство</span><strong>{{.Node.MembershipStatus}}</strong><span>{{.Node.RegistrationStatus}}</span></div>
</div>
<div class="detailsGrid">
{{range .UpdateStatus}}
<div class="detailBox">
<span>{{.Product}}</span>
<strong><span class="pill {{.Tone}}">{{.Phase}} / {{.Status}}</span></strong>
<span>{{.Current}} -> {{.Target}}</span>
<span>{{.ObservedAt}}</span>
{{if .ErrorMessage}}<span>{{.ErrorMessage}}</span>{{end}}
</div>
{{else}}
<div class="detailBox"><span>обновления</span><strong>нет отчетов</strong></div>
{{end}}
</div>
</div>`))
var updatesHTMLPageTemplate = template.Must(template.New("updates-page").Parse(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Обновления фермы</title>
<script src="/api/v1/ui/htmx-lite.js" defer></script>
<style>
:root { color: #172019; background: #eef0ea; font-family: system-ui, "Segoe UI", sans-serif; }
body { margin: 0; }
main { max-width: 1320px; margin: 0 auto; padding: 18px; }
header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; margin-bottom: 12px; }
h1 { margin: 0; font-size: 1.35rem; letter-spacing: 0; }
a { color: #2f6f4f; font-weight: 800; text-decoration: none; }
.muted, .sub { color: #667064; font-size: .82rem; }
.summary { display: grid; grid-template-columns: repeat(5, minmax(110px, 1fr)); gap: 8px; margin-bottom: 10px; }
.metric, .panel { border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; }
.metric { padding: 10px; }
.metric span { display: block; color: #667064; font-size: .76rem; font-weight: 800; }
.metric strong { display: block; margin-top: 4px; font-size: 1.28rem; }
.panel { padding: 10px; margin-bottom: 10px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #1a271b1f; text-align: left; vertical-align: top; padding: 8px; font-size: .86rem; }
th { color: #667064; background: #f8f7ef; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; }
tr:last-child td { border-bottom: 0; }
.pill { display: inline-flex; width: fit-content; border-radius: 8px; padding: .22rem .48rem; font-size: .74rem; font-weight: 800; background: #36556c1a; color: #36556c; }
.good { color: #236c4a; background: #2f6f4f1f; }
.warn { color: #9a5b1c; background: #b86f2324; }
.bad { color: #a64235; background: #b144341f; }
.nav { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.nav a { padding: .48rem .68rem; border: 1px solid #1a271b22; border-radius: 8px; color: #172019; text-decoration: none; font-weight: 800; background: #fffdf5; }
.nav a.active { background: #2f6f4f; color: white; }
.toolbar { display: flex; gap: 8px; justify-content: flex-end; }
button { min-height: 34px; border: 1px solid #1a271b26; border-radius: 8px; background: #fffdf5; color: #172019; padding: .42rem .7rem; cursor: pointer; }
.statusList { display: flex; flex-wrap: wrap; gap: 6px; }
.diagnosis { display: grid; gap: 5px; max-width: 320px; }
.releaseGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 8px; }
.release { border: 1px solid #1a271b1f; border-radius: 8px; background: #ffffff80; padding: 8px; }
.actionResult { display: grid; gap: 7px; margin: 8px 0 10px; }
.bulkList { display: grid; gap: 6px; margin-top: 6px; }
.bulkRow { display: grid; grid-template-columns: minmax(130px, .45fr) auto minmax(240px, 1fr); gap: 8px; align-items: center; border-top: 1px solid #1a271b1f; padding-top: 6px; }
@media (max-width: 820px) { header, .summary { grid-template-columns: 1fr; } th:nth-child(2), td:nth-child(2) { display: none; } main { padding: 10px; } }
</style>
</head>
<body>
<main>
<nav class="nav">{{range .Nav}}<a class="{{if .Active}}active{{end}}" href="{{.URL}}">{{.Label}}</a>{{end}}</nav>
<header>
<div>
<h1>Обновления фермы</h1>
<div class="muted"><a href="/api/v1/clusters/{{.ClusterID}}/ui/nodes?actor_user_id={{.ActorUserID}}">Узлы</a> · серверные расчеты статусов обновления</div>
</div>
<div class="toolbar">
<button type="button" hx-post="{{.BulkCheckNowPath}}" hx-target="#bulk-update-action" hx-swap="innerHTML">Сигнал всем</button>
<button type="button" hx-get="{{.FragmentPath}}" hx-target="#updates-fragment" hx-swap="outerHTML">Обновить</button>
</div>
</header>
<div id="bulk-update-action"></div>
{{template "updates-fragment" .}}
</main>
</body>
</html>`))
var updatesHTMLFragmentTemplate = template.Must(updatesHTMLPageTemplate.New("updates-fragment-only").Parse(`{{template "updates-fragment" .}}`))
var updateCheckNowHTMLTemplate = template.Must(template.New("update-check-now").Parse(`
<div class="actionResult">
<span class="pill {{.Tone}}">{{if .CheckNow}}update hint готов{{else}}нет активной политики{{end}}</span>
<strong>{{.NodeName}}</strong>
<div class="sub">generation: {{.Generation}} · причина: {{.Reason}} · {{.GeneratedAt}}</div>
<div class="sub">доставка: {{.DeliveryMode}} / {{.SubscriptionStatus}} · service: {{.UpdateService}} ({{.UpdateServiceStatus}}) · {{.UpdateServiceFallback}}</div>
<div class="statusList">
{{range .TargetVersions}}
<span class="pill good">{{.}}</span>
{{else}}
<span class="pill warn">целевые версии не заданы</span>
{{end}}
</div>
</div>
`))
var updateCheckNowAllHTMLTemplate = template.Must(template.New("update-check-now-all").Parse(`
<div class="actionResult">
<div class="statusList">
<span class="pill good">сигналы сформированы: {{.Ready}}</span>
<span class="pill warn">без активной политики: {{.NoPolicy}}</span>
<span class="pill">узлов: {{.Total}}</span>
</div>
<div class="sub">{{.GeneratedAt}}</div>
<div class="bulkList">
{{range .Rows}}
<div class="bulkRow">
<strong>{{.NodeName}}</strong>
<span class="pill {{.Tone}}">{{if .CheckNow}}готов{{else}}нет политики{{end}}</span>
<span class="sub">generation: {{.Generation}} · {{.Reason}} · service: {{.UpdateService}} ({{.UpdateServiceStatus}})</span>
</div>
{{else}}
<div class="sub">узлы не найдены</div>
{{end}}
</div>
</div>
`))
func init() {
template.Must(nodesHTMLPageTemplate.New("fragment").Parse(`
<section id="nodes-fragment" hx-get="{{.FragmentPath}}" hx-trigger="load delay:10s, every 15s" hx-swap="outerHTML">
<div class="summary">
<div class="metric"><span>Всего</span><strong>{{.Summary.Total}}</strong></div>
<div class="metric"><span>Свежий отчет узла</span><strong>{{.Summary.Fresh}}</strong></div>
<div class="metric"><span>Текущая версия</span><strong>{{.Summary.Current}}</strong></div>
<div class="metric"><span>Нужна проверка</span><strong>{{.Summary.NeedsReview}}</strong></div>
</div>
<div class="panel">
<table>
<thead>
<tr>
<th>Узел</th>
<th>Состояние</th>
<th>Отчет узла</th>
<th>Версия</th>
<th>Регистрация</th>
<th>Последний сигнал</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td><span class="node">{{.Name}}</span><span class="sub">{{.Group}}</span></td>
<td><span class="pill {{.OperationalTone}}">{{.OperationalState}}</span><span class="sub">{{.Health}}</span></td>
<td><span class="pill {{.FreshnessTone}}">{{.Freshness}}</span></td>
<td><span class="pill {{.VersionTone}}">{{.VersionState}}</span><span class="sub">{{.ReportedVersion}}</span></td>
<td><span class="pill {{.HealthTone}}">{{.RegistrationStatus}}</span><span class="sub">{{.MembershipStatus}}</span></td>
<td>{{.LastSeen}}<span class="sub"><button type="button" hx-get="{{.DetailsPath}}" hx-target="#node-details-{{.ID}}" hx-swap="innerHTML">Детали</button></span></td>
</tr>
<tr><td colspan="6"><div id="node-details-{{.ID}}"></div></td></tr>
{{else}}
<tr><td colspan="6">узлы не найдены</td></tr>
{{end}}
</tbody>
</table>
</div>
<p class="muted">обновлено {{.GeneratedAt}}</p>
</section>
`))
template.Must(updatesHTMLPageTemplate.New("updates-fragment").Parse(`
<section id="updates-fragment" hx-get="{{.FragmentPath}}" hx-trigger="load delay:10s, every 20s" hx-swap="outerHTML">
<div class="summary">
<div class="metric"><span>Узлы</span><strong>{{.Summary.Nodes}}</strong></div>
<div class="metric"><span>Текущие</span><strong>{{.Summary.Current}}</strong></div>
<div class="metric"><span>Не синхронизированы</span><strong>{{.Summary.Outdated}}</strong></div>
<div class="metric"><span>Нет статуса</span><strong>{{.Summary.NoStatus}}</strong></div>
<div class="metric"><span>Ошибки</span><strong>{{.Summary.Failed}}</strong></div>
</div>
<div class="panel">
<div class="releaseGrid">
{{range .Releases}}
<div class="release">
<span class="pill {{.Tone}}">{{.Status}}</span>
<strong>{{.Product}} {{.Version}}</strong>
<div class="sub">{{.Channel}} · artifacts {{.Artifacts}}</div>
</div>
{{else}}
<div class="release">релизы не найдены</div>
{{end}}
</div>
</div>
<div class="panel">
<table>
<thead>
<tr>
<th>Узел</th>
<th>Связь</th>
<th>Версия</th>
<th>Последние обновления</th>
<th>Диагноз</th>
<th>Сигнал</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td><strong>{{.NodeName}}</strong></td>
<td><span class="pill {{.FreshTone}}">{{.Freshness}}</span></td>
<td><span class="pill {{.VersionTone}}">{{.VersionState}}</span></td>
<td>
<div class="statusList">
{{range .Products}}
<span class="pill {{.Tone}}">{{.Product}}: {{.Phase}}/{{.Status}} · {{.Current}} -> {{.Target}}</span>
{{else}}
<span class="pill warn">нет отчетов обновления</span>
{{end}}
</div>
</td>
<td>
<div class="diagnosis">
<span class="pill {{.DiagnosisTone}}">{{.Diagnosis}}</span>
{{if .RuntimeNote}}<span class="sub">{{.RuntimeNote}}</span>{{end}}
</div>
</td>
<td>
<button type="button" class="{{.ActionTone}}" hx-post="{{.CheckNowPath}}" hx-target="#{{.ActionID}}" hx-swap="innerHTML">{{.ActionText}}</button>
<div id="{{.ActionID}}" class="sub"></div>
</td>
</tr>
{{else}}
<tr><td colspan="6">узлы не найдены</td></tr>
{{end}}
</tbody>
</table>
</div>
<p class="muted">обновлено {{.GeneratedAt}}</p>
</section>
`))
}
func renderNodesHTMLFragmentForTest(page nodesHTMLPage) (string, error) {
var buf bytes.Buffer
err := nodesHTMLFragmentTemplate.Execute(&buf, page)
return buf.String(), err
}
const htmxLiteJS = `(() => {
const request = async (el) => {
const method = el.hasAttribute("hx-post") ? "POST" : "GET";
const url = el.getAttribute("hx-post") || el.getAttribute("hx-get");
if (!url) return;
const targetSelector = el.getAttribute("hx-target");
const target = targetSelector ? document.querySelector(targetSelector) : el;
if (!target) return;
const options = { method, headers: { "HX-Request": "true" }, credentials: "same-origin" };
if (method === "POST" && el.tagName === "FORM") {
options.body = new URLSearchParams(new FormData(el));
options.headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
}
const response = await fetch(url, options);
if (!response.ok) return;
const html = await response.text();
const swap = el.getAttribute("hx-swap") || "innerHTML";
if (swap === "outerHTML") {
target.outerHTML = html;
bind(document);
return;
}
target.innerHTML = html;
bind(target);
};
const schedule = (el) => {
const trigger = el.getAttribute("hx-trigger") || "";
if (el.dataset.hxBound === "true") return;
el.dataset.hxBound = "true";
if (trigger.includes("load")) {
const delay = /load\s+delay:(\d+)s/.exec(trigger);
window.setTimeout(() => request(el), delay ? Number(delay[1]) * 1000 : 0);
}
const every = /every\s+(\d+)s/.exec(trigger);
if (every) window.setInterval(() => request(el), Number(every[1]) * 1000);
};
const bind = (root) => {
root.querySelectorAll("[hx-get], [hx-post]").forEach((el) => {
if (!el.dataset.hxClickBound) {
el.dataset.hxClickBound = "true";
const eventName = el.tagName === "FORM" ? "submit" : "click";
el.addEventListener(eventName, (event) => {
if ((el.getAttribute("hx-trigger") || "").includes("every")) return;
event.preventDefault();
request(el);
});
}
schedule(el);
});
};
document.addEventListener("DOMContentLoaded", () => bind(document));
})();`