1456 lines
53 KiB
Go
1456 lines
53 KiB
Go
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));
|
||
})();`
|