1486 lines
71 KiB
Go
1486 lines
71 KiB
Go
package cluster
|
||
|
||
import (
|
||
"encoding/json"
|
||
"html/template"
|
||
"math"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
||
)
|
||
|
||
type consoleNavItem struct {
|
||
Label string
|
||
URL string
|
||
Active bool
|
||
}
|
||
|
||
type farmOverviewHTMLPage struct {
|
||
Cluster Cluster
|
||
ActorUserID string
|
||
GeneratedAt string
|
||
Nav []consoleNavItem
|
||
Metrics []consoleMetricHTML
|
||
Nodes []nodesHTMLRow
|
||
Links []fabricLinkHTML
|
||
Events []auditEventHTML
|
||
}
|
||
|
||
type fabricConsoleHTMLPage struct {
|
||
Cluster Cluster
|
||
ActorUserID string
|
||
GeneratedAt string
|
||
Nav []consoleNavItem
|
||
Readiness FabricServiceChannelReadiness
|
||
RebuildHealth FabricServiceChannelRouteRebuildHealthSummary
|
||
Access FabricServiceChannelAccessTelemetry
|
||
Recovery FabricServiceChannelRecoveryPolicy
|
||
Adaptive FabricServiceChannelAdaptivePolicy
|
||
Pool FabricServiceChannelPoolPolicy
|
||
Links []fabricLinkHTML
|
||
Routes []MeshRouteIntent
|
||
Channels []FabricServiceChannelAccessTelemetryChannel
|
||
Result *webControlResultHTML
|
||
}
|
||
|
||
type topologyHTMLPage struct {
|
||
Cluster Cluster
|
||
ActorUserID string
|
||
GeneratedAt string
|
||
Nav []consoleNavItem
|
||
Summary []consoleMetricHTML
|
||
Filters []topologyFilterHTML
|
||
FilterGroups []topologyFilterGroupHTML
|
||
Insights []topologyInsightHTML
|
||
Legend []topologyLegendHTML
|
||
Nodes []topologyNodeHTML
|
||
Links []topologyLinkHTML
|
||
Rows []fabricLinkHTML
|
||
}
|
||
|
||
type topologyFilterHTML struct {
|
||
Key string
|
||
Label string
|
||
Hint string
|
||
Checked bool
|
||
Count int
|
||
}
|
||
|
||
type topologyFilterGroupHTML struct {
|
||
Title string
|
||
InputName string
|
||
Filters []topologyFilterHTML
|
||
}
|
||
|
||
type topologyInsightHTML struct {
|
||
Title string
|
||
Value string
|
||
Tone string
|
||
Sub string
|
||
}
|
||
|
||
type topologyLegendHTML struct {
|
||
Name string
|
||
Tone string
|
||
Text string
|
||
}
|
||
|
||
type webControlHTMLPage struct {
|
||
Cluster Cluster
|
||
ActorUserID string
|
||
GeneratedAt string
|
||
Nav []consoleNavItem
|
||
Rows []webControlNodeHTML
|
||
Result *webControlResultHTML
|
||
}
|
||
|
||
type auditConsoleHTMLPage struct {
|
||
Cluster Cluster
|
||
ActorUserID string
|
||
GeneratedAt string
|
||
Nav []consoleNavItem
|
||
Summary ClusterAuditSummary
|
||
Events []auditEventHTML
|
||
}
|
||
|
||
type consoleMetricHTML struct {
|
||
Label string
|
||
Value string
|
||
Sub string
|
||
Tone string
|
||
}
|
||
|
||
type fabricLinkHTML struct {
|
||
Source string
|
||
Target string
|
||
Status string
|
||
Tone string
|
||
Mode string
|
||
Observation string
|
||
Latency string
|
||
Quality string
|
||
ObservedAt string
|
||
}
|
||
|
||
type topologyNodeHTML struct {
|
||
ID string
|
||
Name string
|
||
Tone string
|
||
Status string
|
||
Version string
|
||
X int
|
||
Y int
|
||
}
|
||
|
||
type topologyLinkHTML struct {
|
||
SourceName string
|
||
TargetName string
|
||
Kind string
|
||
TransportClass string
|
||
Label string
|
||
Tone string
|
||
Status string
|
||
Latency string
|
||
Quality string
|
||
Path string
|
||
MarkerURL string
|
||
Tooltip string
|
||
}
|
||
|
||
type topologyLinkCandidate struct {
|
||
SourceID string
|
||
TargetID string
|
||
Kind string
|
||
TransportClass string
|
||
Label string
|
||
Tone string
|
||
Status string
|
||
Latency string
|
||
Quality string
|
||
Tooltip string
|
||
Suspicious bool
|
||
Observed time.Time
|
||
}
|
||
|
||
type auditEventHTML struct {
|
||
Type string
|
||
Target string
|
||
Tone string
|
||
CreatedAt string
|
||
Summary string
|
||
}
|
||
|
||
type webControlNodeHTML struct {
|
||
NodeID string
|
||
NodeName string
|
||
Freshness string
|
||
FreshTone string
|
||
Admin webControlServiceHTML
|
||
Public webControlServiceHTML
|
||
}
|
||
|
||
type webControlServiceHTML struct {
|
||
ServiceType string
|
||
DesiredState string
|
||
DesiredTone string
|
||
RuntimeMode string
|
||
Version string
|
||
ReportedState string
|
||
ReportedTone string
|
||
ObservedAt string
|
||
}
|
||
|
||
type webControlResultHTML struct {
|
||
Tone string
|
||
Title string
|
||
Message string
|
||
}
|
||
|
||
func (m *Module) renderFarmOverviewHTML(w http.ResponseWriter, r *http.Request) {
|
||
page, err := m.farmOverviewHTMLModel(r)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := farmOverviewHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render farm overview")
|
||
}
|
||
}
|
||
|
||
func (m *Module) renderFabricConsoleHTML(w http.ResponseWriter, r *http.Request) {
|
||
page, err := m.fabricConsoleHTMLModel(r, nil)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := fabricConsoleHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render fabric console")
|
||
}
|
||
}
|
||
|
||
func (m *Module) renderTopologyHTML(w http.ResponseWriter, r *http.Request) {
|
||
page, err := m.topologyHTMLModel(r)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := topologyHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render topology")
|
||
}
|
||
}
|
||
|
||
func (m *Module) renderFabricPolicyHTML(w http.ResponseWriter, r *http.Request) {
|
||
result, err := m.applyFabricPolicy(r)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
page, err := m.fabricConsoleHTMLModel(r, &result)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := fabricConsoleFragmentHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render fabric policy fragment")
|
||
}
|
||
}
|
||
|
||
func (m *Module) renderWebControlHTML(w http.ResponseWriter, r *http.Request) {
|
||
page, err := m.webControlHTMLModel(r, nil)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := webControlHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render web control")
|
||
}
|
||
}
|
||
|
||
func (m *Module) renderWebControlDesiredHTML(w http.ResponseWriter, r *http.Request) {
|
||
result, err := m.applyWebControlDesired(r)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
page, err := m.webControlHTMLModel(r, &result)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := webControlFragmentHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render web control fragment")
|
||
}
|
||
}
|
||
|
||
func (m *Module) renderAuditConsoleHTML(w http.ResponseWriter, r *http.Request) {
|
||
page, err := m.auditConsoleHTMLModel(r)
|
||
if writeServiceError(w, err) {
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := auditConsoleHTMLTemplate.Execute(w, page); err != nil {
|
||
httpx.WriteError(w, http.StatusInternalServerError, "render audit console")
|
||
}
|
||
}
|
||
|
||
func (m *Module) farmOverviewHTMLModel(r *http.Request) (farmOverviewHTMLPage, error) {
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
cluster, nodes, err := m.consoleClusterAndNodes(r, actorUserID, clusterID)
|
||
if err != nil {
|
||
return farmOverviewHTMLPage{}, err
|
||
}
|
||
now := time.Now().UTC()
|
||
rows := make([]nodesHTMLRow, 0, len(nodes))
|
||
summary := nodesHTMLSummary{Total: len(nodes)}
|
||
for _, node := range nodes {
|
||
row := nodesHTMLRowFromNode(node, now)
|
||
rows = append(rows, row)
|
||
if row.FreshnessTone == "good" {
|
||
summary.Fresh++
|
||
}
|
||
if row.VersionTone == "good" {
|
||
summary.Current++
|
||
}
|
||
if row.OperationalTone != "good" {
|
||
summary.NeedsReview++
|
||
}
|
||
}
|
||
sort.SliceStable(rows, func(i, j int) bool { return toneRank(rows[i].OperationalTone) < toneRank(rows[j].OperationalTone) })
|
||
readiness, _ := m.service.GetFabricServiceChannelReadiness(r.Context(), actorUserID, GetFabricServiceChannelReadinessInput{ClusterID: clusterID, Limit: 30})
|
||
access, _ := m.service.GetFabricServiceChannelAccessTelemetry(r.Context(), actorUserID, GetFabricServiceChannelAccessTelemetryInput{ClusterID: clusterID, Limit: 12})
|
||
links, _ := m.service.ListMeshLinks(r.Context(), actorUserID, clusterID)
|
||
events, _ := m.service.ListAuditEvents(r.Context(), actorUserID, ListAuditEventsInput{ClusterID: clusterID, Limit: 8})
|
||
page := farmOverviewHTMLPage{
|
||
Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(now),
|
||
Nav: consoleNav(clusterID, actorUserID, "overview"),
|
||
Nodes: rows, Links: fabricLinkRows(links, 10), Events: auditEventRows(events),
|
||
}
|
||
page.Metrics = []consoleMetricHTML{
|
||
{"узлы", displayInt(summary.Total), displayInt(summary.Fresh) + " со свежим отчетом", toneFromCount(summary.NeedsReview)},
|
||
{"версии", displayInt(summary.Current), displayInt(summary.Total-summary.Current) + " требуют синхронизации", toneFromCount(summary.Total - summary.Current)},
|
||
{"fabric", displayStatus(readiness.Status), displayStatus(readiness.Reason), statusTone(readiness.Status)},
|
||
{"каналы", displayInt(access.ActiveChannelCount), "accepted " + displayInt(access.TotalAccepted), statusTone(access.Status)},
|
||
}
|
||
return page, nil
|
||
}
|
||
|
||
func (m *Module) fabricConsoleHTMLModel(r *http.Request, result *webControlResultHTML) (fabricConsoleHTMLPage, error) {
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
cluster, _, err := m.consoleClusterAndNodes(r, actorUserID, clusterID)
|
||
if err != nil {
|
||
return fabricConsoleHTMLPage{}, err
|
||
}
|
||
readiness, err := m.service.GetFabricServiceChannelReadiness(r.Context(), actorUserID, GetFabricServiceChannelReadinessInput{ClusterID: clusterID, Limit: 80})
|
||
if err != nil {
|
||
return fabricConsoleHTMLPage{}, err
|
||
}
|
||
health, _ := m.service.GetFabricServiceChannelRouteRebuildHealthSummary(r.Context(), actorUserID, GetFabricServiceChannelRouteRebuildHealthSummaryInput{ClusterID: clusterID, Limit: 80})
|
||
access, _ := m.service.GetFabricServiceChannelAccessTelemetry(r.Context(), actorUserID, GetFabricServiceChannelAccessTelemetryInput{ClusterID: clusterID, Limit: 40})
|
||
recovery, _ := m.service.GetFabricServiceChannelRecoveryPolicy(r.Context(), actorUserID, clusterID)
|
||
adaptive, _ := m.service.GetFabricServiceChannelAdaptivePolicy(r.Context(), actorUserID, clusterID)
|
||
pool, _ := m.service.GetFabricServiceChannelPoolPolicy(r.Context(), actorUserID, clusterID)
|
||
links, _ := m.service.ListMeshLinks(r.Context(), actorUserID, clusterID)
|
||
routes, _ := m.service.ListRouteIntents(r.Context(), actorUserID, clusterID)
|
||
return fabricConsoleHTMLPage{
|
||
Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(time.Now().UTC()),
|
||
Nav: consoleNav(clusterID, actorUserID, "fabric"),
|
||
Readiness: readiness, RebuildHealth: health, Access: access, Recovery: recovery, Adaptive: adaptive, Pool: pool,
|
||
Links: fabricLinkRows(links, 18), Routes: routes, Channels: access.ActiveChannels, Result: result,
|
||
}, nil
|
||
}
|
||
|
||
func (m *Module) topologyHTMLModel(r *http.Request) (topologyHTMLPage, error) {
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
cluster, nodes, err := m.consoleClusterAndNodes(r, actorUserID, clusterID)
|
||
if err != nil {
|
||
return topologyHTMLPage{}, err
|
||
}
|
||
links, _ := m.service.ListMeshLinks(r.Context(), actorUserID, clusterID)
|
||
now := time.Now().UTC()
|
||
topologyNodes, nodeByID := topologyNodeRows(nodes, now)
|
||
candidates := m.topologyLinkCandidates(r, clusterID, nodes, links)
|
||
filters := topologyFiltersFromQuery(r)
|
||
stateFilters := topologyStateFiltersFromQuery(r)
|
||
topologyLinks := topologyLinkRows(candidates, nodeByID, filters, stateFilters)
|
||
insights := topologyInsights(candidates, nodeByID)
|
||
factReachable, factProblem, plannedRoutes := topologyFactSummary(candidates)
|
||
return topologyHTMLPage{
|
||
Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(now),
|
||
Nav: consoleNav(clusterID, actorUserID, "topology"),
|
||
Filters: topologyFilterRows(candidates, filters),
|
||
FilterGroups: topologyFilterGroups(candidates, filters, stateFilters),
|
||
Insights: insights,
|
||
Legend: topologyLegendRows(),
|
||
Nodes: topologyNodes, Links: topologyLinks, Rows: fabricLinkRows(links, 80),
|
||
Summary: []consoleMetricHTML{
|
||
{"узлы", displayInt(len(topologyNodes)), "на карте", toneFromCount(0)},
|
||
{"факт", displayInt(factReachable), "reachable наблюдения", "good"},
|
||
{"план", displayInt(plannedRoutes), "route hops в config", "muted"},
|
||
{"проверить", displayInt(factProblem), "degraded/unreachable факт", toneFromCount(factProblem)},
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func (m *Module) webControlHTMLModel(r *http.Request, result *webControlResultHTML) (webControlHTMLPage, error) {
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
cluster, nodes, err := m.consoleClusterAndNodes(r, actorUserID, clusterID)
|
||
if err != nil {
|
||
return webControlHTMLPage{}, err
|
||
}
|
||
now := time.Now().UTC()
|
||
page := webControlHTMLPage{
|
||
Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(now),
|
||
Nav: consoleNav(clusterID, actorUserID, "web-control"), Result: result,
|
||
Rows: make([]webControlNodeHTML, 0, len(nodes)),
|
||
}
|
||
for _, node := range nodes {
|
||
row := nodesHTMLRowFromNode(node, now)
|
||
desired, _ := m.service.ListDesiredWorkloads(r.Context(), actorUserID, clusterID, node.ID)
|
||
statuses, _ := m.service.ListLatestWorkloadStatuses(r.Context(), actorUserID, clusterID, node.ID)
|
||
page.Rows = append(page.Rows, webControlNodeHTML{
|
||
NodeID: node.ID, NodeName: node.Name, Freshness: row.Freshness, FreshTone: row.FreshnessTone,
|
||
Admin: webControlServiceFrom("admin-ingress", desired, statuses),
|
||
Public: webControlServiceFrom("public-ingress", desired, statuses),
|
||
})
|
||
}
|
||
return page, nil
|
||
}
|
||
|
||
func (m *Module) auditConsoleHTMLModel(r *http.Request) (auditConsoleHTMLPage, error) {
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
cluster, _, err := m.consoleClusterAndNodes(r, actorUserID, clusterID)
|
||
if err != nil {
|
||
return auditConsoleHTMLPage{}, err
|
||
}
|
||
events, err := m.service.ListAuditEvents(r.Context(), actorUserID, ListAuditEventsInput{ClusterID: clusterID, Limit: 80})
|
||
if err != nil {
|
||
return auditConsoleHTMLPage{}, err
|
||
}
|
||
return auditConsoleHTMLPage{
|
||
Cluster: cluster, ActorUserID: actorUserID, GeneratedAt: consoleTime(time.Now().UTC()),
|
||
Nav: consoleNav(clusterID, actorUserID, "audit"), Events: auditEventRows(events), Summary: summarizeClusterAuditEvents(events),
|
||
}, nil
|
||
}
|
||
|
||
func (m *Module) applyWebControlDesired(r *http.Request) (webControlResultHTML, error) {
|
||
if err := r.ParseForm(); err != nil {
|
||
return webControlResultHTML{}, ErrInvalidPayload
|
||
}
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
nodeID := strings.TrimSpace(r.FormValue("node_id"))
|
||
serviceType := strings.TrimSpace(r.FormValue("service_type"))
|
||
desired := strings.TrimSpace(r.FormValue("desired_state"))
|
||
runtimeMode := strings.TrimSpace(r.FormValue("runtime_mode"))
|
||
version := strings.TrimSpace(r.FormValue("version"))
|
||
if serviceType != "admin-ingress" && serviceType != "public-ingress" {
|
||
return webControlResultHTML{}, ErrInvalidPayload
|
||
}
|
||
var versionPtr *string
|
||
if version != "" {
|
||
versionPtr = &version
|
||
}
|
||
_, err := m.service.SetDesiredWorkload(r.Context(), SetDesiredWorkloadInput{
|
||
ActorUserID: actorUserID, ClusterID: clusterID, NodeID: nodeID, ServiceType: serviceType,
|
||
DesiredState: firstNonEmptyString(desired, "disabled"), RuntimeMode: firstNonEmptyString(runtimeMode, "container"),
|
||
Version: versionPtr, Config: json.RawMessage(`{"control_plane":"quic_fabric"}`), Environment: json.RawMessage(`{}`),
|
||
})
|
||
if err != nil {
|
||
return webControlResultHTML{}, err
|
||
}
|
||
return webControlResultHTML{Tone: "good", Title: "настройка сохранена", Message: serviceType + " на узле обновлен через desired state"}, nil
|
||
}
|
||
|
||
func (m *Module) applyFabricPolicy(r *http.Request) (webControlResultHTML, error) {
|
||
if err := r.ParseForm(); err != nil {
|
||
return webControlResultHTML{}, ErrInvalidPayload
|
||
}
|
||
clusterID, actorUserID := chi.URLParam(r, "clusterID"), strings.TrimSpace(r.URL.Query().Get("actor_user_id"))
|
||
switch strings.TrimSpace(r.FormValue("policy")) {
|
||
case "recovery":
|
||
rebuild := formBoolPtr(r, "demotion_rebuild_enabled")
|
||
fenced := formBoolPtr(r, "demotion_fenced_enabled")
|
||
_, err := m.service.UpdateFabricServiceChannelRecoveryPolicy(r.Context(), UpdateFabricServiceChannelRecoveryPolicyInput{
|
||
ActorUserID: actorUserID, ClusterID: clusterID,
|
||
HysteresisPenalty: formInt(r, "hysteresis_penalty"), PromotionMinSamples: formInt(r, "promotion_min_samples"),
|
||
DemotionFailureThreshold: formInt(r, "demotion_failure_threshold"), DemotionDropThreshold: formInt(r, "demotion_drop_threshold"),
|
||
DemotionSlowThreshold: formInt(r, "demotion_slow_threshold"), DemotionRebuildEnabled: rebuild, DemotionFencedEnabled: fenced,
|
||
})
|
||
if err != nil {
|
||
return webControlResultHTML{}, err
|
||
}
|
||
return webControlResultHTML{Tone: "good", Title: "recovery policy сохранена", Message: "изменения записаны в cluster metadata и подписываются control-plane"}, nil
|
||
case "adaptive":
|
||
_, err := m.service.UpdateFabricServiceChannelAdaptivePolicy(r.Context(), UpdateFabricServiceChannelAdaptivePolicyInput{
|
||
ActorUserID: actorUserID, ClusterID: clusterID,
|
||
MaxParallelWindow: formInt(r, "max_parallel_window"), BulkPressureChannelThreshold: formInt(r, "bulk_pressure_channel_threshold"),
|
||
QueuePressureHighWatermark: formInt(r, "queue_pressure_high_watermark"), QueuePressureMaxInFlight: formInt(r, "queue_pressure_max_in_flight"),
|
||
})
|
||
if err != nil {
|
||
return webControlResultHTML{}, err
|
||
}
|
||
return webControlResultHTML{Tone: "good", Title: "adaptive policy сохранена", Message: "новые пределы backpressure применяются к fabric service channels"}, nil
|
||
case "pool":
|
||
degraded := formBoolPtr(r, "degraded_route_allowed")
|
||
sticky := formBoolPtr(r, "sticky_session")
|
||
_, err := m.service.UpdateFabricServiceChannelPoolPolicy(r.Context(), UpdateFabricServiceChannelPoolPolicyInput{
|
||
ActorUserID: actorUserID, ClusterID: clusterID,
|
||
EntryPoolNodeIDs: formCSV(r, "entry_pool_node_ids"), ExitPoolNodeIDs: formCSV(r, "exit_pool_node_ids"),
|
||
PreferredEntryNodeID: strings.TrimSpace(r.FormValue("preferred_entry_node_id")), PreferredExitNodeID: strings.TrimSpace(r.FormValue("preferred_exit_node_id")),
|
||
SelectionStrategy: strings.TrimSpace(r.FormValue("selection_strategy")), RouteRebuild: strings.TrimSpace(r.FormValue("route_rebuild")),
|
||
EntryFailover: strings.TrimSpace(r.FormValue("entry_failover")), ExitFailover: strings.TrimSpace(r.FormValue("exit_failover")),
|
||
CompatFallbackAllowed: degraded, StickySession: sticky,
|
||
})
|
||
if err != nil {
|
||
return webControlResultHTML{}, err
|
||
}
|
||
return webControlResultHTML{Tone: "good", Title: "pool policy сохранена", Message: "пулы входа/выхода и rebuild policy обновлены"}, nil
|
||
default:
|
||
return webControlResultHTML{}, ErrInvalidPayload
|
||
}
|
||
}
|
||
|
||
func (m *Module) consoleClusterAndNodes(r *http.Request, actorUserID, clusterID string) (Cluster, []ClusterNode, error) {
|
||
cluster, err := m.service.GetCluster(r.Context(), actorUserID, clusterID)
|
||
if err != nil {
|
||
return Cluster{}, nil, err
|
||
}
|
||
nodes, err := m.service.ListClusterNodes(r.Context(), actorUserID, clusterID)
|
||
if err != nil {
|
||
return Cluster{}, nil, err
|
||
}
|
||
return cluster, nodes, nil
|
||
}
|
||
|
||
func consoleNav(clusterID, actorUserID, active string) []consoleNavItem {
|
||
query := ""
|
||
if strings.TrimSpace(actorUserID) != "" {
|
||
query = "?actor_user_id=" + template.URLQueryEscaper(actorUserID)
|
||
}
|
||
items := []struct{ key, label, path string }{
|
||
{"overview", "Обзор", "overview"}, {"nodes", "Узлы", "nodes"}, {"updates", "Обновления", "updates"},
|
||
{"topology", "Топология", "topology"}, {"fabric", "Fabric", "fabric"}, {"web-control", "Веб-контроль", "web-control"}, {"audit", "Аудит", "audit"},
|
||
}
|
||
out := make([]consoleNavItem, 0, len(items))
|
||
for _, item := range items {
|
||
out = append(out, consoleNavItem{Label: item.label, URL: "/api/v1/clusters/" + clusterID + "/ui/" + item.path + query, Active: item.key == active})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyNodeRows(nodes []ClusterNode, now time.Time) ([]topologyNodeHTML, map[string]topologyNodeHTML) {
|
||
out := make([]topologyNodeHTML, 0, len(nodes))
|
||
nodeByID := map[string]topologyNodeHTML{}
|
||
if len(nodes) == 0 {
|
||
return out, nodeByID
|
||
}
|
||
sort.SliceStable(nodes, func(i, j int) bool {
|
||
return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name)
|
||
})
|
||
centerX, centerY := 500.0, 310.0
|
||
radiusX, radiusY := 360.0, 220.0
|
||
if len(nodes) == 1 {
|
||
radiusX, radiusY = 0, 0
|
||
}
|
||
for index, node := range nodes {
|
||
angle := (2 * math.Pi * float64(index) / float64(len(nodes))) - math.Pi/2
|
||
row := nodesHTMLRowFromNode(node, now)
|
||
item := topologyNodeHTML{
|
||
ID: node.ID, Name: node.Name, Tone: row.OperationalTone, Status: row.OperationalState,
|
||
Version: row.ReportedVersion, X: int(math.Round(centerX + radiusX*math.Cos(angle))), Y: int(math.Round(centerY + radiusY*math.Sin(angle))),
|
||
}
|
||
out = append(out, item)
|
||
nodeByID[item.ID] = item
|
||
}
|
||
return out, nodeByID
|
||
}
|
||
|
||
func (m *Module) topologyLinkCandidates(r *http.Request, clusterID string, nodes []ClusterNode, observed []MeshLinkObservation) []topologyLinkCandidate {
|
||
out := make([]topologyLinkCandidate, 0, len(observed))
|
||
perspectives := m.topologyEndpointPerspectives(r, clusterID, nodes)
|
||
for _, link := range observed {
|
||
latency, quality := "нет", "нет"
|
||
if link.LatencyMs != nil {
|
||
latency = displayInt(*link.LatencyMs) + " ms"
|
||
}
|
||
if link.QualityScore != nil {
|
||
quality = displayInt(*link.QualityScore)
|
||
}
|
||
status := displayStatus(link.LinkStatus)
|
||
metadata := meshObservationMetadata(link.Metadata)
|
||
transport := firstNonEmptyString(metadata["transport_mode"], metadata["transport"], "unknown")
|
||
observationType := firstNonEmptyString(metadata["observation_type"], "mesh_probe")
|
||
connectionState := firstNonEmptyString(metadata["connection_state"], metadata["state"])
|
||
relayNodeID := metadata["relay_node_id"]
|
||
rendezvousLeaseID := metadata["rendezvous_lease_id"]
|
||
failureReason := metadata["failure_reason"]
|
||
transportClass := topologyTransportClass(transport, observationType)
|
||
tone := statusTone(link.LinkStatus)
|
||
suspicious := topologySuspiciousDirectInbound(link, transport, perspectives[link.TargetNodeID])
|
||
if suspicious {
|
||
tone = "bad"
|
||
transportClass += " transport-suspicious"
|
||
}
|
||
tooltipParts := []string{
|
||
"фактическая QUIC fabric связь узел-узел",
|
||
"статус: " + status,
|
||
"transport_mode: " + transport,
|
||
"observation_type: " + observationType,
|
||
"latency: " + latency,
|
||
"quality: " + quality,
|
||
"наблюдение: " + consoleTime(link.ObservedAt),
|
||
}
|
||
if connectionState != "" {
|
||
tooltipParts = append(tooltipParts, "connection_state: "+connectionState)
|
||
}
|
||
if relayNodeID != "" {
|
||
tooltipParts = append(tooltipParts, "relay_node_id: "+relayNodeID)
|
||
}
|
||
if rendezvousLeaseID != "" {
|
||
tooltipParts = append(tooltipParts, "rendezvous_lease_id: "+rendezvousLeaseID)
|
||
}
|
||
if failureReason != "" {
|
||
tooltipParts = append(tooltipParts, "failure_reason: "+failureReason)
|
||
}
|
||
if suspicious {
|
||
tooltipParts = append(tooltipParts, "подозрение: входящий direct к NAT/outbound-only узлу; для такого узла нормальна исходящая связь или relay/rendezvous")
|
||
}
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: link.SourceNodeID, TargetID: link.TargetNodeID, Kind: "observed", TransportClass: transportClass, Label: "transport fact",
|
||
Tone: tone, Status: status, Latency: latency, Quality: quality, Suspicious: suspicious, Observed: link.ObservedAt,
|
||
Tooltip: strings.Join(tooltipParts, "\n"),
|
||
})
|
||
}
|
||
for _, node := range nodes {
|
||
cfg, err := m.service.GetNodeSyntheticMeshConfig(r.Context(), GetNodeSyntheticMeshConfigInput{ClusterID: clusterID, NodeID: node.ID})
|
||
if err != nil || !cfg.Enabled {
|
||
continue
|
||
}
|
||
out = append(out, topologyRouteCandidates(node.ID, cfg)...)
|
||
out = append(out, topologyDirectoryCandidates(node.ID, cfg)...)
|
||
out = append(out, topologyEndpointCandidates(node.ID, cfg)...)
|
||
out = append(out, topologyRecoverySeedCandidates(node.ID, cfg)...)
|
||
out = append(out, topologyRendezvousCandidates(node.ID, cfg)...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
type topologyEndpointPerspective struct {
|
||
OutboundOnly bool
|
||
ConnectivityMode string
|
||
NATType string
|
||
PeerEndpoint string
|
||
Candidates []PeerEndpointCandidate
|
||
}
|
||
|
||
func (m *Module) topologyEndpointPerspectives(r *http.Request, clusterID string, nodes []ClusterNode) map[string]topologyEndpointPerspective {
|
||
out := make(map[string]topologyEndpointPerspective, len(nodes))
|
||
for _, node := range nodes {
|
||
heartbeats, err := m.service.ListNodeHeartbeats(r.Context(), r.URL.Query().Get("actor_user_id"), clusterID, node.ID, 1)
|
||
if err != nil || len(heartbeats) == 0 {
|
||
continue
|
||
}
|
||
out[node.ID] = topologyEndpointPerspectiveFromHeartbeat(heartbeats[0])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyEndpointPerspectiveFromHeartbeat(heartbeat NodeHeartbeat) topologyEndpointPerspective {
|
||
var metadata struct {
|
||
MeshEndpointReport heartbeatMeshEndpointReport `json:"mesh_endpoint_report"`
|
||
FabricListenerReport struct {
|
||
InboundReachability string `json:"inbound_reachability"`
|
||
OneWayConnectivity bool `json:"one_way_connectivity"`
|
||
} `json:"fabric_listener_report"`
|
||
}
|
||
if len(heartbeat.Metadata) == 0 || !json.Valid(heartbeat.Metadata) {
|
||
return topologyEndpointPerspective{}
|
||
}
|
||
if err := json.Unmarshal(heartbeat.Metadata, &metadata); err != nil {
|
||
return topologyEndpointPerspective{}
|
||
}
|
||
return topologyEndpointPerspective{
|
||
OutboundOnly: metadata.FabricListenerReport.OneWayConnectivity || strings.EqualFold(metadata.FabricListenerReport.InboundReachability, "outbound_only") || strings.EqualFold(metadata.MeshEndpointReport.ConnectivityMode, "outbound_only"),
|
||
ConnectivityMode: strings.TrimSpace(metadata.MeshEndpointReport.ConnectivityMode),
|
||
NATType: strings.TrimSpace(metadata.MeshEndpointReport.NATType),
|
||
PeerEndpoint: strings.TrimSpace(metadata.MeshEndpointReport.PeerEndpoint),
|
||
Candidates: metadata.MeshEndpointReport.EndpointCandidates,
|
||
}
|
||
}
|
||
|
||
func meshObservationMetadata(raw json.RawMessage) map[string]string {
|
||
out := map[string]string{}
|
||
if len(raw) == 0 || !json.Valid(raw) {
|
||
return out
|
||
}
|
||
values := map[string]any{}
|
||
if err := json.Unmarshal(raw, &values); err != nil {
|
||
return out
|
||
}
|
||
for _, key := range []string{"transport_mode", "transport", "observation_type", "connection_state", "state", "relay_node_id", "rendezvous_lease_id", "failure_reason"} {
|
||
out[key] = meshMetadataString(values, key)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyTransportClass(transport, observationType string) string {
|
||
value := strings.ToLower(strings.TrimSpace(firstNonEmptyString(transport, observationType)))
|
||
switch {
|
||
case strings.Contains(value, "relay") || strings.Contains(strings.ToLower(observationType), "relay"):
|
||
return "transport-relay"
|
||
case strings.Contains(value, "private_lan"):
|
||
return "transport-private-lan"
|
||
case strings.Contains(value, "direct"):
|
||
return "transport-direct"
|
||
default:
|
||
return "transport-unknown"
|
||
}
|
||
}
|
||
|
||
func topologySuspiciousDirectInbound(link MeshLinkObservation, transport string, target topologyEndpointPerspective) bool {
|
||
if link.SourceNodeID == "" || link.TargetNodeID == "" || link.SourceNodeID == link.TargetNodeID || !strings.EqualFold(displayStatus(link.LinkStatus), "reachable") {
|
||
return false
|
||
}
|
||
value := strings.ToLower(strings.TrimSpace(transport))
|
||
if strings.Contains(value, "relay") || strings.Contains(value, "rendezvous") {
|
||
return false
|
||
}
|
||
if !strings.Contains(value, "direct") && !strings.Contains(value, "private_lan") {
|
||
return false
|
||
}
|
||
return topologyTargetNeedsOutboundOrRelay(target)
|
||
}
|
||
|
||
func topologyTargetNeedsOutboundOrRelay(target topologyEndpointPerspective) bool {
|
||
connectivity := strings.ToLower(strings.TrimSpace(target.ConnectivityMode))
|
||
natType := strings.ToLower(strings.TrimSpace(target.NATType))
|
||
if target.OutboundOnly || connectivity == "outbound_only" || connectivity == "relay_required" || natType == "symmetric" || natType == "blocked" {
|
||
return true
|
||
}
|
||
if target.PeerEndpoint == "" && len(target.Candidates) == 0 {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func topologyRouteCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate {
|
||
out := []topologyLinkCandidate{}
|
||
for _, route := range cfg.Routes {
|
||
path := cleanRouteNodePath(route.Hops)
|
||
if len(path) < 2 {
|
||
continue
|
||
}
|
||
for index := 0; index < len(path)-1; index++ {
|
||
channels := strings.Join(route.AllowedChannels, ", ")
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: path[index], TargetID: path[index+1], Kind: "route", Label: "план маршрута",
|
||
Tone: "muted", Status: firstNonEmptyString(channels, "route"), Observed: route.ExpiresAt,
|
||
Tooltip: "запланированный маршрут\nroute: " + route.RouteID + "\nchannels: " + channels + "\nhop: " + displayInt(index+1) + " из " + displayInt(len(path)-1) + "\nисточник конфига: " + localNodeID,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyDirectoryCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate {
|
||
out := []topologyLinkCandidate{}
|
||
for _, entry := range cfg.PeerDirectory {
|
||
if entry.NodeID == "" || entry.NodeID == localNodeID {
|
||
continue
|
||
}
|
||
tone := "good"
|
||
if entry.EndpointCount == 0 && entry.CandidateCount == 0 {
|
||
tone = "warn"
|
||
}
|
||
status := displayInt(entry.EndpointCount) + "/" + displayInt(entry.CandidateCount)
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: localNodeID, TargetID: entry.NodeID, Kind: "directory", Label: "память узла",
|
||
Tone: tone, Status: status,
|
||
Tooltip: "peer directory\nузел хранит запись о peer\nendpoints: " + displayInt(entry.EndpointCount) + "\ncandidates: " + displayInt(entry.CandidateCount) + "\nmodes: " + strings.Join(entry.ConnectivityModes, ", ") + "\nroutes: " + strings.Join(entry.RouteIDs, ", "),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyEndpointCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate {
|
||
out := []topologyLinkCandidate{}
|
||
for peerNodeID, candidates := range cfg.PeerEndpointCandidates {
|
||
if peerNodeID == "" || peerNodeID == localNodeID || len(candidates) == 0 {
|
||
continue
|
||
}
|
||
best := candidates[0]
|
||
for _, candidate := range candidates[1:] {
|
||
if candidate.Priority < best.Priority {
|
||
best = candidate
|
||
}
|
||
}
|
||
tone := "good"
|
||
if endpointCandidateRequiresRendezvous(best) {
|
||
tone = "warn"
|
||
}
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: localNodeID, TargetID: peerNodeID, Kind: "endpoint", Label: "адрес-кандидат",
|
||
Tone: tone, Status: firstNonEmptyString(best.ConnectivityMode, best.Reachability),
|
||
Tooltip: "endpoint candidates\nузел знает адреса peer\ntransport: " + best.Transport + "\nmode: " + best.ConnectivityMode + "\nreachability: " + best.Reachability + "\nregion: " + best.Region + "\nкандидатов: " + displayInt(len(candidates)),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyRecoverySeedCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate {
|
||
out := []topologyLinkCandidate{}
|
||
for _, seed := range cfg.RecoverySeeds {
|
||
if seed.NodeID == "" || seed.NodeID == localNodeID {
|
||
continue
|
||
}
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: localNodeID, TargetID: seed.NodeID, Kind: "recovery", Label: "recovery",
|
||
Tone: "warn", Status: firstNonEmptyString(seed.ConnectivityMode, seed.Transport), Observed: timeValue(seed.LastVerifiedAt),
|
||
Tooltip: "recovery seed\nрезервная точка восстановления связи\ntransport: " + seed.Transport + "\nmode: " + seed.ConnectivityMode + "\nregion: " + seed.Region + "\nverified: " + consoleTime(timeValue(seed.LastVerifiedAt)),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyRendezvousCandidates(localNodeID string, cfg NodeSyntheticMeshConfig) []topologyLinkCandidate {
|
||
out := []topologyLinkCandidate{}
|
||
for _, lease := range cfg.RendezvousLeases {
|
||
if lease.RelayNodeID != "" && lease.RelayNodeID != localNodeID {
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: localNodeID, TargetID: lease.RelayNodeID, Kind: "rendezvous", Label: "rendezvous",
|
||
Tone: "warn", Status: firstNonEmptyString(lease.ConnectivityMode, lease.Transport), Observed: lease.IssuedAt,
|
||
Tooltip: "rendezvous lease\nузел использует relay для связи через NAT\npeer: " + lease.PeerNodeID + "\nrelay: " + lease.RelayNodeID + "\nroutes: " + strings.Join(lease.RouteIDs, ", ") + "\nexpires: " + consoleTime(lease.ExpiresAt),
|
||
})
|
||
}
|
||
if lease.RelayNodeID != "" && lease.PeerNodeID != "" && lease.RelayNodeID != lease.PeerNodeID {
|
||
out = append(out, topologyLinkCandidate{
|
||
SourceID: lease.RelayNodeID, TargetID: lease.PeerNodeID, Kind: "rendezvous", Label: "rendezvous",
|
||
Tone: "warn", Status: firstNonEmptyString(lease.ConnectivityMode, lease.Transport), Observed: lease.IssuedAt,
|
||
Tooltip: "rendezvous relay path\nrelay отдает связь peer\nзапросивший узел: " + localNodeID + "\nroutes: " + strings.Join(lease.RouteIDs, ", ") + "\nexpires: " + consoleTime(lease.ExpiresAt),
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func timeValue(value *time.Time) time.Time {
|
||
if value == nil {
|
||
return time.Time{}
|
||
}
|
||
return *value
|
||
}
|
||
|
||
func topologyFiltersFromQuery(r *http.Request) map[string]bool {
|
||
selected := map[string]bool{}
|
||
values := append([]string{}, r.URL.Query()["filter"]...)
|
||
values = append(values, r.URL.Query()["link"]...)
|
||
if len(values) == 0 {
|
||
for _, key := range topologyFilterKeyOrder() {
|
||
selected[key] = true
|
||
}
|
||
return selected
|
||
}
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
selected[value] = true
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func topologyStateFiltersFromQuery(r *http.Request) map[string]bool {
|
||
selected := map[string]bool{}
|
||
values := r.URL.Query()["state"]
|
||
if len(values) == 0 {
|
||
for _, key := range topologyStateFilterKeyOrder() {
|
||
selected[key] = true
|
||
}
|
||
return selected
|
||
}
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
selected[value] = true
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func topologyFilterRows(links []topologyLinkCandidate, selected map[string]bool) []topologyFilterHTML {
|
||
groups := topologyTypeFilterGroups(links, selected)
|
||
out := []topologyFilterHTML{}
|
||
for _, group := range groups {
|
||
out = append(out, group.Filters...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyFilterGroups(links []topologyLinkCandidate, selected map[string]bool, stateSelected map[string]bool) []topologyFilterGroupHTML {
|
||
groups := topologyTypeFilterGroups(links, selected)
|
||
groups = append(groups, topologyStateFilterGroup(links, stateSelected))
|
||
return groups
|
||
}
|
||
|
||
func topologyTypeFilterGroups(links []topologyLinkCandidate, selected map[string]bool) []topologyFilterGroupHTML {
|
||
counts := map[string]int{}
|
||
for _, link := range links {
|
||
for _, key := range topologyFilterKeysForLink(link) {
|
||
counts[key]++
|
||
}
|
||
}
|
||
groups := []topologyFilterGroupHTML{
|
||
{Title: "Нижний транспорт", InputName: "filter", Filters: topologyFilterRowsForGroup([]string{"observed:direct", "observed:private_lan", "observed:relay", "observed:unknown", "observed:suspicious"}, counts, selected)},
|
||
{Title: "Память и планы", InputName: "filter", Filters: topologyFilterRowsForGroup([]string{"route", "directory", "endpoint"}, counts, selected)},
|
||
{Title: "NAT и живучесть", InputName: "filter", Filters: topologyFilterRowsForGroup([]string{"rendezvous", "recovery"}, counts, selected)},
|
||
}
|
||
return groups
|
||
}
|
||
|
||
func topologyStateFilterGroup(links []topologyLinkCandidate, selected map[string]bool) topologyFilterGroupHTML {
|
||
counts := map[string]int{}
|
||
for _, link := range links {
|
||
counts[topologyStateFilterKey(link)]++
|
||
}
|
||
return topologyFilterGroupHTML{
|
||
Title: "Состояние",
|
||
InputName: "state",
|
||
Filters: topologyFilterRowsForGroup(topologyStateFilterKeyOrder(), counts, selected),
|
||
}
|
||
}
|
||
|
||
func topologyFilterRowsForGroup(keys []string, counts map[string]int, selected map[string]bool) []topologyFilterHTML {
|
||
out := make([]topologyFilterHTML, 0, len(keys))
|
||
for _, key := range keys {
|
||
out = append(out, topologyFilterHTML{Key: key, Label: topologyFilterLabel(key), Hint: topologyFilterHint(key), Checked: selected[key], Count: counts[key]})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyFilterKeyOrder() []string {
|
||
return []string{"observed:direct", "observed:private_lan", "observed:relay", "observed:unknown", "observed:suspicious", "route", "directory", "endpoint", "rendezvous", "recovery"}
|
||
}
|
||
|
||
func topologyStateFilterKeyOrder() []string {
|
||
return []string{"state:reachable", "state:problem", "state:derived"}
|
||
}
|
||
|
||
func topologyStateFilterKey(link topologyLinkCandidate) string {
|
||
if link.Kind != "observed" {
|
||
return "state:derived"
|
||
}
|
||
status := strings.ToLower(strings.TrimSpace(link.Status))
|
||
if status == "reachable" || status == "healthy" || status == "ready" {
|
||
return "state:reachable"
|
||
}
|
||
return "state:problem"
|
||
}
|
||
|
||
func topologyFilterKeysForLink(link topologyLinkCandidate) []string {
|
||
if link.Kind != "observed" {
|
||
return []string{link.Kind}
|
||
}
|
||
keys := []string{topologyObservedTransportFilterKey(link.TransportClass)}
|
||
if link.Suspicious {
|
||
keys = append(keys, "observed:suspicious")
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func topologyObservedTransportFilterKey(class string) string {
|
||
switch {
|
||
case strings.Contains(class, "transport-relay"):
|
||
return "observed:relay"
|
||
case strings.Contains(class, "transport-private-lan"):
|
||
return "observed:private_lan"
|
||
case strings.Contains(class, "transport-direct"):
|
||
return "observed:direct"
|
||
default:
|
||
return "observed:unknown"
|
||
}
|
||
}
|
||
|
||
func topologyFilterLabel(key string) string {
|
||
labels := map[string]string{
|
||
"observed:direct": "direct",
|
||
"observed:private_lan": "private LAN",
|
||
"observed:relay": "relay",
|
||
"observed:unknown": "unknown",
|
||
"observed:suspicious": "подозрительный direct",
|
||
"route": "route plan",
|
||
"directory": "peer directory",
|
||
"endpoint": "endpoint",
|
||
"rendezvous": "rendezvous",
|
||
"recovery": "recovery seed",
|
||
"state:reachable": "reachable",
|
||
"state:problem": "degraded / waiting",
|
||
"state:derived": "не факт",
|
||
}
|
||
return firstNonEmptyString(labels[key], key)
|
||
}
|
||
|
||
func topologyFilterHint(key string) string {
|
||
hints := map[string]string{
|
||
"observed:direct": "публичный или прямой QUIC transport",
|
||
"observed:private_lan": "реальная связь внутри одной LAN/подсети",
|
||
"observed:relay": "факт через relay/rendezvous путь",
|
||
"observed:unknown": "факт без полного transport metadata",
|
||
"observed:suspicious": "входящий direct к NAT/outbound-only цели",
|
||
"route": "рассчитанный hop маршрута",
|
||
"directory": "запись, которую узел хранит о peer",
|
||
"endpoint": "адрес-кандидат, по нему еще надо пробовать",
|
||
"rendezvous": "аренда посредника для NAT",
|
||
"recovery": "запасная точка восстановления",
|
||
"state:reachable": "реально поднятая или успешно проверенная связь",
|
||
"state:problem": "фактическая проверка есть, но связь не готова",
|
||
"state:derived": "план, адрес или запись памяти, не проверенная связь",
|
||
}
|
||
return hints[key]
|
||
}
|
||
|
||
func topologyFactSummary(links []topologyLinkCandidate) (reachable int, problem int, planned int) {
|
||
for _, link := range links {
|
||
switch link.Kind {
|
||
case "observed":
|
||
status := strings.ToLower(strings.TrimSpace(link.Status))
|
||
if status == "reachable" || status == "healthy" || status == "ready" {
|
||
reachable++
|
||
} else {
|
||
problem++
|
||
}
|
||
case "route":
|
||
planned++
|
||
}
|
||
}
|
||
return reachable, problem, planned
|
||
}
|
||
|
||
func topologyInsights(links []topologyLinkCandidate, nodeByID map[string]topologyNodeHTML) []topologyInsightHTML {
|
||
observedPairs := map[string]bool{}
|
||
targetObserved := map[string]int{}
|
||
plannedPairs := map[string]bool{}
|
||
endpointPairs := map[string]bool{}
|
||
rendezvousPairs := map[string]bool{}
|
||
recoveryPairs := map[string]bool{}
|
||
suspiciousDirect := 0
|
||
for _, link := range links {
|
||
key := link.SourceID + "\x00" + link.TargetID
|
||
switch link.Kind {
|
||
case "observed":
|
||
if strings.EqualFold(link.Status, "reachable") {
|
||
observedPairs[key] = true
|
||
targetObserved[link.TargetID]++
|
||
}
|
||
if link.Suspicious {
|
||
suspiciousDirect++
|
||
}
|
||
case "route":
|
||
plannedPairs[key] = true
|
||
case "endpoint":
|
||
endpointPairs[key] = true
|
||
case "rendezvous":
|
||
rendezvousPairs[key] = true
|
||
case "recovery":
|
||
recoveryPairs[key] = true
|
||
}
|
||
}
|
||
unconfirmed := 0
|
||
for key := range plannedPairs {
|
||
if !observedPairs[key] {
|
||
unconfirmed++
|
||
}
|
||
}
|
||
missingEndpoint := 0
|
||
for key := range plannedPairs {
|
||
if !endpointPairs[key] {
|
||
missingEndpoint++
|
||
}
|
||
}
|
||
hotNodeID, hotCount := "", 0
|
||
for nodeID, count := range targetObserved {
|
||
if count > hotCount {
|
||
hotNodeID, hotCount = nodeID, count
|
||
}
|
||
}
|
||
hotName := "нет"
|
||
hotTone := "good"
|
||
if hotNodeID != "" {
|
||
hotName = firstNonEmptyString(nodeByID[hotNodeID].Name, hotNodeID)
|
||
}
|
||
if len(observedPairs) > 0 && hotCount*100/len(observedPairs) >= 50 {
|
||
hotTone = "warn"
|
||
}
|
||
return []topologyInsightHTML{
|
||
{Title: "не подтверждено", Value: displayInt(unconfirmed), Tone: toneFromCount(unconfirmed), Sub: "route без reachable observation"},
|
||
{Title: "нет адреса", Value: displayInt(missingEndpoint), Tone: toneFromCount(missingEndpoint), Sub: "route без endpoint candidate"},
|
||
{Title: "сомнительный direct", Value: displayInt(suspiciousDirect), Tone: toneFromCount(suspiciousDirect), Sub: "входящий direct к NAT/outbound-only"},
|
||
{Title: "relay", Value: displayInt(len(rendezvousPairs)), Tone: toneFromCount(len(rendezvousPairs)), Sub: "rendezvous зависимость"},
|
||
{Title: "центр тяжести", Value: hotName, Tone: hotTone, Sub: displayInt(hotCount) + " входящих фактических связей"},
|
||
{Title: "recovery", Value: displayInt(len(recoveryPairs)), Tone: "warn", Sub: "запасные пути восстановления"},
|
||
}
|
||
}
|
||
|
||
func topologyLegendRows() []topologyLegendHTML {
|
||
return []topologyLegendHTML{
|
||
{Name: "Transport", Tone: "good", Text: "transport fact: реальная проверенная QUIC связь узел-узел"},
|
||
{Name: "План", Tone: "muted", Text: "route: узлы должны иметь путь по synthetic mesh config"},
|
||
{Name: "Адрес", Tone: "muted", Text: "endpoint: куда можно пробовать QUIC, это еще не связь"},
|
||
{Name: "Relay", Tone: "warn", Text: "rendezvous: путь через посредника при NAT или недоступном direct"},
|
||
{Name: "Direct/NAT", Tone: "bad", Text: "красный direct к NAT/outbound-only требует проверки, нормален outbound или relay"},
|
||
{Name: "Recovery", Tone: "warn", Text: "seed: запасная точка восстановления связи"},
|
||
}
|
||
}
|
||
|
||
func topologyLinkKindOrder() []string {
|
||
return []string{"observed", "route", "directory", "endpoint", "rendezvous", "recovery"}
|
||
}
|
||
|
||
func topologyLinkKindLabel(key string) string {
|
||
labels := map[string]string{"observed": "transport факт", "route": "маршруты", "directory": "peer directory", "endpoint": "endpoint", "rendezvous": "rendezvous", "recovery": "recovery"}
|
||
return firstNonEmptyString(labels[key], key)
|
||
}
|
||
|
||
func topologyLinkRows(links []topologyLinkCandidate, nodeByID map[string]topologyNodeHTML, filters map[string]bool, stateFilters map[string]bool) []topologyLinkHTML {
|
||
sort.SliceStable(links, func(i, j int) bool {
|
||
return links[i].Observed.After(links[j].Observed)
|
||
})
|
||
out := []topologyLinkHTML{}
|
||
pairCount := map[string]int{}
|
||
for _, link := range links {
|
||
if !topologyLinkPassesFilters(link, filters, stateFilters) {
|
||
continue
|
||
}
|
||
source, okSource := nodeByID[link.SourceID]
|
||
target, okTarget := nodeByID[link.TargetID]
|
||
if !okSource || !okTarget {
|
||
continue
|
||
}
|
||
pairKey := source.ID + "\x00" + target.ID
|
||
pairCount[pairKey]++
|
||
path := topologyEdgePath(source.X, source.Y, target.X, target.Y, pairCount[pairKey])
|
||
tone := firstNonEmptyString(link.Tone, "muted")
|
||
tooltip := source.Name + " -> " + target.Name + "\nтип: " + link.Label + "\n" + link.Tooltip
|
||
out = append(out, topologyLinkHTML{
|
||
SourceName: source.Name, TargetName: target.Name, Kind: link.Kind, TransportClass: link.TransportClass, Label: link.Label, Tone: tone, Status: link.Status,
|
||
Latency: link.Latency, Quality: link.Quality, Path: path, MarkerURL: "url(#arrow-" + tone + ")", Tooltip: tooltip,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func topologyLinkPassesFilters(link topologyLinkCandidate, filters map[string]bool, stateFilters map[string]bool) bool {
|
||
if !stateFilters[topologyStateFilterKey(link)] {
|
||
return false
|
||
}
|
||
for _, key := range topologyFilterKeysForLink(link) {
|
||
if filters[key] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func topologyEdgePath(x1, y1, x2, y2 int, lane int) string {
|
||
dx := float64(x2 - x1)
|
||
dy := float64(y2 - y1)
|
||
length := math.Hypot(dx, dy)
|
||
if length == 0 {
|
||
length = 1
|
||
}
|
||
startOffset := 39.0
|
||
endOffset := 45.0
|
||
sx := float64(x1) + dx/length*startOffset
|
||
sy := float64(y1) + dy/length*startOffset
|
||
ex := float64(x2) - dx/length*endOffset
|
||
ey := float64(y2) - dy/length*endOffset
|
||
perpX := -dy / length
|
||
perpY := dx / length
|
||
laneOffset := float64((lane+1)/2) * 16
|
||
if lane%2 == 0 {
|
||
laneOffset = -laneOffset
|
||
}
|
||
curve := math.Min(78, math.Max(26, length*0.14)) + laneOffset
|
||
cx := (sx+ex)/2 + perpX*curve
|
||
cy := (sy+ey)/2 + perpY*curve
|
||
return "M " + displayInt(int(math.Round(sx))) + " " + displayInt(int(math.Round(sy))) +
|
||
" Q " + displayInt(int(math.Round(cx))) + " " + displayInt(int(math.Round(cy))) +
|
||
" " + displayInt(int(math.Round(ex))) + " " + displayInt(int(math.Round(ey)))
|
||
}
|
||
|
||
func webControlServiceFrom(serviceType string, desired []NodeWorkloadDesiredState, statuses []NodeWorkloadStatus) webControlServiceHTML {
|
||
out := webControlServiceHTML{ServiceType: serviceType, DesiredState: "disabled", DesiredTone: "warn", RuntimeMode: "container", Version: "", ReportedState: "нет отчета", ReportedTone: "warn", ObservedAt: "никогда"}
|
||
for _, item := range desired {
|
||
if item.ServiceType == serviceType {
|
||
out.DesiredState = firstNonEmptyString(item.DesiredState, "disabled")
|
||
out.DesiredTone = statusTone(out.DesiredState)
|
||
out.RuntimeMode = firstNonEmptyString(item.RuntimeMode, "container")
|
||
if item.Version != nil {
|
||
out.Version = strings.TrimSpace(*item.Version)
|
||
}
|
||
}
|
||
}
|
||
for _, status := range statuses {
|
||
if status.ServiceType == serviceType {
|
||
out.ReportedState = displayStatus(status.ReportedState)
|
||
out.ReportedTone = statusTone(status.ReportedState)
|
||
out.ObservedAt = consoleTime(status.ObservedAt)
|
||
if status.Version != nil && out.Version == "" {
|
||
out.Version = strings.TrimSpace(*status.Version)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func fabricLinkRows(input []MeshLinkObservation, limit int) []fabricLinkHTML {
|
||
sort.SliceStable(input, func(i, j int) bool { return input[i].ObservedAt.After(input[j].ObservedAt) })
|
||
if limit > 0 && len(input) > limit {
|
||
input = input[:limit]
|
||
}
|
||
out := make([]fabricLinkHTML, 0, len(input))
|
||
for _, item := range input {
|
||
latency, quality := "нет", "нет"
|
||
if item.LatencyMs != nil {
|
||
latency = displayInt(*item.LatencyMs) + " ms"
|
||
}
|
||
if item.QualityScore != nil {
|
||
quality = displayInt(*item.QualityScore)
|
||
}
|
||
metadata := meshObservationMetadata(item.Metadata)
|
||
out = append(out, fabricLinkHTML{
|
||
Source: item.SourceNodeID, Target: item.TargetNodeID, Status: displayStatus(item.LinkStatus), Tone: statusTone(item.LinkStatus),
|
||
Mode: firstNonEmptyString(metadata["transport_mode"], metadata["transport"], "unknown"), Observation: firstNonEmptyString(metadata["observation_type"], "mesh_probe"),
|
||
Latency: latency, Quality: quality, ObservedAt: consoleTime(item.ObservedAt),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func auditEventRows(input []ClusterAuditEvent) []auditEventHTML {
|
||
out := make([]auditEventHTML, 0, len(input))
|
||
for _, event := range input {
|
||
target := event.TargetType
|
||
if event.TargetID != nil && strings.TrimSpace(*event.TargetID) != "" {
|
||
target += " · " + strings.TrimSpace(*event.TargetID)
|
||
}
|
||
out = append(out, auditEventHTML{Type: displayStatus(event.EventType), Target: target, Tone: auditTone(event.EventType), CreatedAt: consoleTime(event.CreatedAt), Summary: auditSummary(event.Payload)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func auditTone(eventType string) string {
|
||
if strings.Contains(eventType, "blocked") || strings.Contains(eventType, "failed") {
|
||
return "bad"
|
||
}
|
||
if strings.Contains(eventType, "triggered") || strings.Contains(eventType, "updated") || strings.Contains(eventType, "set") {
|
||
return "good"
|
||
}
|
||
return "warn"
|
||
}
|
||
|
||
func auditSummary(raw json.RawMessage) string {
|
||
if len(raw) == 0 {
|
||
return ""
|
||
}
|
||
var payload map[string]any
|
||
if json.Unmarshal(raw, &payload) != nil {
|
||
return string(raw)
|
||
}
|
||
keys := make([]string, 0, len(payload))
|
||
for key := range payload {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
if len(keys) > 4 {
|
||
keys = keys[:4]
|
||
}
|
||
return strings.Join(keys, ", ")
|
||
}
|
||
|
||
func toneFromCount(count int) string {
|
||
if count > 0 {
|
||
return "warn"
|
||
}
|
||
return "good"
|
||
}
|
||
|
||
func formInt(r *http.Request, key string) int {
|
||
value, _ := strconv.Atoi(strings.TrimSpace(r.FormValue(key)))
|
||
return value
|
||
}
|
||
|
||
func formBoolPtr(r *http.Request, key string) *bool {
|
||
value := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
|
||
out := value == "true" || value == "on" || value == "1" || value == "enabled"
|
||
return &out
|
||
}
|
||
|
||
func formCSV(r *http.Request, key string) []string {
|
||
raw := strings.NewReplacer("\r", ",", "\n", ",", ";", ",").Replace(r.FormValue(key))
|
||
out := []string{}
|
||
for _, part := range strings.Split(raw, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part != "" {
|
||
out = append(out, part)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func consoleTime(value time.Time) string {
|
||
if value.IsZero() {
|
||
return "не задано"
|
||
}
|
||
return value.UTC().Format("2006-01-02 15:04:05 UTC")
|
||
}
|
||
|
||
var farmOverviewHTMLTemplate = template.Must(template.New("farm-overview").Parse(consoleHTMLHead("Обзор фермы") + `
|
||
<body><main>
|
||
{{template "console-nav" .}}
|
||
<section class="hero">
|
||
<div><h1>{{.Cluster.Name}}</h1><p>{{.Cluster.Slug}} · {{.Cluster.Status}} · обновлено {{.GeneratedAt}}</p></div>
|
||
</section>
|
||
<section class="metrics">{{range .Metrics}}<div class="metric"><span>{{.Label}}</span><strong>{{.Value}}</strong><small class="{{.Tone}}">{{.Sub}}</small></div>{{end}}</section>
|
||
<section class="layout2">
|
||
<div class="panel"><h2>Состояние узлов</h2><table><tbody>{{range .Nodes}}<tr><td><strong>{{.Name}}</strong><small>{{.ReportedVersion}}</small></td><td><span class="pill {{.FreshnessTone}}">{{.Freshness}}</span></td><td><span class="pill {{.OperationalTone}}">{{.OperationalState}}</span></td></tr>{{end}}</tbody></table></div>
|
||
<div class="panel"><h2>Визуальная работа fabric</h2>{{template "fabric-links" .Links}}</div>
|
||
</section>
|
||
<section class="panel"><h2>Последние события</h2>{{template "audit-list" .Events}}</section>
|
||
</main></body></html>`))
|
||
|
||
var fabricConsoleHTMLTemplate = template.Must(template.New("fabric-console").Parse(consoleHTMLHead("Fabric") + `
|
||
<body><main>
|
||
{{template "console-nav" .}}
|
||
{{template "fabric-console-fragment" .}}
|
||
</main></body></html>`))
|
||
|
||
var fabricConsoleFragmentHTMLTemplate = template.Must(fabricConsoleHTMLTemplate.New("fabric-console-fragment-only").Parse(`{{template "fabric-console-fragment" .}}`))
|
||
|
||
var topologyHTMLTemplate = template.Must(template.New("topology").Funcs(template.FuncMap{"add": templateAdd, "mid": templateMid, "initial": templateInitial}).Parse(consoleHTMLHead("Топология") + `
|
||
<body><main>
|
||
{{template "console-nav" .}}
|
||
<section class="hero"><div><h1>Топология фермы</h1><p>{{.Cluster.Name}} · визуальное отображение узлов и QUIC fabric связей · {{.GeneratedAt}}</p></div><span class="pill good">fabric map</span></section>
|
||
<section class="metrics">{{range .Summary}}<div class="metric"><span>{{.Label}}</span><strong>{{.Value}}</strong><small class="{{.Tone}}">{{.Sub}}</small></div>{{end}}</section>
|
||
<section class="insightGrid">{{range .Insights}}<div class="insight"><span>{{.Title}}</span><strong class="{{.Tone}}">{{.Value}}</strong><small>{{.Sub}}</small></div>{{end}}</section>
|
||
<section class="panel">
|
||
<form class="filterBar" method="get" action="/api/v1/clusters/{{.Cluster.ID}}/ui/topology">
|
||
<input type="hidden" name="actor_user_id" value="{{.ActorUserID}}">
|
||
<div class="filterTitle"><strong>Отображать связи</strong><button type="submit">Применить</button></div>
|
||
<div class="filterGroups">
|
||
{{range $group := .FilterGroups}}<fieldset><legend>{{$group.Title}}</legend>{{range $group.Filters}}<label title="{{.Hint}}"><input type="checkbox" name="{{$group.InputName}}" value="{{.Key}}" {{if .Checked}}checked{{end}}> {{.Label}} <span>{{.Count}}</span></label>{{end}}</fieldset>{{end}}
|
||
</div>
|
||
</form>
|
||
<div class="legend">{{range .Legend}}<span><i class="{{.Tone}}"></i><strong>{{.Name}}</strong> {{.Text}}</span>{{end}}</div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="topologyCanvas">
|
||
<svg viewBox="0 0 1000 620" role="img" aria-label="Карта связей узлов фермы">
|
||
<defs>
|
||
<marker id="arrow-good" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth"><path d="M2,2 L8,5 L2,8 Z" class="arrow good"></path></marker>
|
||
<marker id="arrow-warn" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth"><path d="M2,2 L8,5 L2,8 Z" class="arrow warn"></path></marker>
|
||
<marker id="arrow-bad" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth"><path d="M2,2 L8,5 L2,8 Z" class="arrow bad"></path></marker>
|
||
<marker id="arrow-muted" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth"><path d="M2,2 L8,5 L2,8 Z" class="arrow muted"></path></marker>
|
||
</defs>
|
||
<rect x="1" y="1" width="998" height="618" rx="18" class="topologyBg"></rect>
|
||
{{range .Links}}
|
||
<path d="{{.Path}}" class="edge {{.Tone}} {{.Kind}} {{.TransportClass}}" marker-end="{{.MarkerURL}}"><title>{{.Tooltip}}</title></path>
|
||
{{end}}
|
||
{{range .Nodes}}
|
||
<g class="nodeGroup">
|
||
<title>{{.Name}}
|
||
статус: {{.Status}}
|
||
версия: {{.Version}}</title>
|
||
<circle cx="{{.X}}" cy="{{.Y}}" r="30" class="nodeCircle {{.Tone}}"></circle>
|
||
<text x="{{.X}}" y="{{.Y}}" class="nodeInitial">{{initial .Name}}</text>
|
||
<text x="{{.X}}" y="{{add .Y 52}}" class="nodeName">{{.Name}}</text>
|
||
<text x="{{.X}}" y="{{add .Y 68}}" class="nodeMeta">{{.Status}}</text>
|
||
</g>
|
||
{{end}}
|
||
</svg>
|
||
</div>
|
||
</section>
|
||
<section class="layout2">
|
||
<div class="panel"><h2>Узлы на карте</h2><table><tbody>{{range .Nodes}}<tr><td><strong>{{.Name}}</strong><small>{{.Version}}</small></td><td><span class="pill {{.Tone}}">{{.Status}}</span></td></tr>{{else}}<tr><td>узлы не найдены</td></tr>{{end}}</tbody></table></div>
|
||
<div class="panel"><h2>Фактические наблюдения</h2><table><tbody>{{range .Rows}}<tr><td><strong>{{.Source}}</strong><small>{{.Target}}</small></td><td><span class="pill {{.Tone}}">{{.Status}}</span><small>{{.Mode}} · {{.Observation}}</small></td><td>{{.Latency}} · q {{.Quality}}</td></tr>{{else}}<tr><td>связи не найдены</td></tr>{{end}}</tbody></table></div>
|
||
</section>
|
||
</main></body></html>`))
|
||
|
||
var webControlHTMLTemplate = template.Must(template.New("web-control").Parse(consoleHTMLHead("Веб-контроль") + `
|
||
<body><main>
|
||
{{template "console-nav" .}}
|
||
<section class="hero"><div><h1>Веб-контроль панели</h1><p>Настройка admin-ingress и public-ingress как workloads фермы. Управляющий доступ остается через QUIC fabric.</p></div></section>
|
||
{{template "web-control-fragment" .}}
|
||
</main></body></html>`))
|
||
|
||
var webControlFragmentHTMLTemplate = template.Must(webControlHTMLTemplate.New("web-control-fragment-only").Parse(`{{template "web-control-fragment" .}}`))
|
||
|
||
var auditConsoleHTMLTemplate = template.Must(template.New("audit-console").Parse(consoleHTMLHead("Аудит") + `
|
||
<body><main>
|
||
{{template "console-nav" .}}
|
||
<section class="hero"><div><h1>Аудит фермы</h1><p>{{.Cluster.Name}} · событий {{.Summary.TotalCount}} · обновлено {{.GeneratedAt}}</p></div></section>
|
||
<section class="panel">{{template "audit-list" .Events}}</section>
|
||
</main></body></html>`))
|
||
|
||
func init() {
|
||
for _, tmpl := range []*template.Template{farmOverviewHTMLTemplate, fabricConsoleHTMLTemplate, topologyHTMLTemplate, webControlHTMLTemplate, auditConsoleHTMLTemplate} {
|
||
template.Must(tmpl.New("console-nav").Parse(`<nav class="nav">{{range .Nav}}<a class="{{if .Active}}active{{end}}" href="{{.URL}}">{{.Label}}</a>{{end}}</nav>`))
|
||
template.Must(tmpl.New("fabric-links").Parse(`<div class="links">{{range .}}<div class="link"><strong>{{.Source}}</strong><span class="line {{.Tone}}"></span><strong>{{.Target}}</strong><small>{{.Status}} · {{.Mode}} · {{.Observation}} · {{.Latency}} · q {{.Quality}}</small></div>{{else}}<p class="muted">связи не найдены</p>{{end}}</div>`))
|
||
template.Must(tmpl.New("audit-list").Parse(`<table><tbody>{{range .}}<tr><td><span class="pill {{.Tone}}">{{.Type}}</span></td><td><strong>{{.Target}}</strong><small>{{.Summary}}</small></td><td>{{.CreatedAt}}</td></tr>{{else}}<tr><td>событий нет</td></tr>{{end}}</tbody></table>`))
|
||
}
|
||
template.Must(fabricConsoleHTMLTemplate.New("fabric-console-fragment").Parse(`
|
||
<section id="fabric-console-fragment">
|
||
{{if .Result}}<div class="result {{.Result.Tone}}"><strong>{{.Result.Title}}</strong><span>{{.Result.Message}}</span></div>{{end}}
|
||
<section class="hero"><div><h1>QUIC fabric</h1><p>{{.Cluster.Name}} · аналитика маршрутов, каналов и восстановления</p></div><span class="pill {{.Readiness.Status}}">{{.Readiness.Status}}</span></section>
|
||
<section class="metrics">
|
||
<div class="metric"><span>readiness</span><strong>{{.Readiness.Status}}</strong><small>{{.Readiness.Reason}}</small></div>
|
||
<div class="metric"><span>alerts</span><strong>{{.Readiness.ActiveAlertCount}}</strong><small>bad {{.Readiness.ActiveBadCount}} · warn {{.Readiness.ActiveWarnCount}}</small></div>
|
||
<div class="metric"><span>access</span><strong>{{.Access.ActiveChannelCount}}</strong><small>accepted {{.Access.TotalAccepted}}</small></div>
|
||
<div class="metric"><span>rebuild</span><strong>{{.RebuildHealth.TotalAttempts}}</strong><small>applied {{.RebuildHealth.AppliedCount}} · pending {{.RebuildHealth.PendingCount}}</small></div>
|
||
</section>
|
||
<section class="panel"><h2>Настройка fabric policies</h2>
|
||
<div class="policyGrid">
|
||
<form class="miniForm" hx-post="/api/v1/clusters/{{.Cluster.ID}}/ui/fabric/policy?actor_user_id={{.ActorUserID}}" hx-target="#fabric-console-fragment" hx-swap="outerHTML">
|
||
<input type="hidden" name="policy" value="recovery"><strong>Recovery</strong>
|
||
<div class="formRow"><input name="hysteresis_penalty" value="{{.Recovery.HysteresisPenalty}}" placeholder="hysteresis"><input name="promotion_min_samples" value="{{.Recovery.PromotionMinSamples}}" placeholder="samples"><input name="demotion_failure_threshold" value="{{.Recovery.DemotionFailureThreshold}}" placeholder="failures"></div>
|
||
<div class="formRow"><input name="demotion_drop_threshold" value="{{.Recovery.DemotionDropThreshold}}" placeholder="drops"><input name="demotion_slow_threshold" value="{{.Recovery.DemotionSlowThreshold}}" placeholder="slow"><label><input type="checkbox" name="demotion_rebuild_enabled" value="true"> rebuild</label><label><input type="checkbox" name="demotion_fenced_enabled" value="true"> fence</label><button type="submit">Сохранить</button></div>
|
||
<small>{{.Recovery.Fingerprint}}</small>
|
||
</form>
|
||
<form class="miniForm" hx-post="/api/v1/clusters/{{.Cluster.ID}}/ui/fabric/policy?actor_user_id={{.ActorUserID}}" hx-target="#fabric-console-fragment" hx-swap="outerHTML">
|
||
<input type="hidden" name="policy" value="adaptive"><strong>Adaptive backpressure</strong>
|
||
<div class="formRow"><input name="max_parallel_window" value="{{.Adaptive.MaxParallelWindow}}" placeholder="parallel"><input name="bulk_pressure_channel_threshold" value="{{.Adaptive.BulkPressureChannelThreshold}}" placeholder="bulk threshold"></div>
|
||
<div class="formRow"><input name="queue_pressure_high_watermark" value="{{.Adaptive.QueuePressureHighWatermark}}" placeholder="high watermark"><input name="queue_pressure_max_in_flight" value="{{.Adaptive.QueuePressureMaxInFlight}}" placeholder="max in-flight"><button type="submit">Сохранить</button></div>
|
||
<small>{{.Adaptive.Fingerprint}}</small>
|
||
</form>
|
||
<form class="miniForm" hx-post="/api/v1/clusters/{{.Cluster.ID}}/ui/fabric/policy?actor_user_id={{.ActorUserID}}" hx-target="#fabric-console-fragment" hx-swap="outerHTML">
|
||
<input type="hidden" name="policy" value="pool"><strong>Route pools</strong>
|
||
<div class="formRow"><input name="entry_pool_node_ids" value="{{range .Pool.EntryPoolNodeIDs}}{{.}},{{end}}" placeholder="entry node ids"><input name="exit_pool_node_ids" value="{{range .Pool.ExitPoolNodeIDs}}{{.}},{{end}}" placeholder="exit node ids"></div>
|
||
<div class="formRow"><input name="preferred_entry_node_id" value="{{.Pool.PreferredEntryNodeID}}" placeholder="preferred entry"><input name="preferred_exit_node_id" value="{{.Pool.PreferredExitNodeID}}" placeholder="preferred exit"></div>
|
||
<div class="formRow"><select name="selection_strategy"><option value="quality_weighted">quality_weighted</option><option value="sticky_preferred">sticky_preferred</option><option value="round_robin">round_robin</option></select><select name="route_rebuild"><option value="automatic">automatic</option><option value="manual">manual</option></select><input name="entry_failover" value="{{.Pool.EntryFailover}}" placeholder="entry failover"><input name="exit_failover" value="{{.Pool.ExitFailover}}" placeholder="exit failover"><label><input type="checkbox" name="sticky_session" value="true"> sticky</label><button type="submit">Сохранить</button></div>
|
||
<small>{{.Pool.Fingerprint}}</small>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
<section class="layout2">
|
||
<div class="panel"><h2>Каналы</h2><table><tbody>{{range .Channels}}<tr><td><strong>{{.ServiceClass}}</strong><small>{{.ChannelID}}</small></td><td>{{.SelectedEntryNodeID}} -> {{.SelectedExitNodeID}}</td><td><span class="pill {{.Status}}">{{.Status}}</span></td></tr>{{else}}<tr><td>активных каналов нет</td></tr>{{end}}</tbody></table></div>
|
||
<div class="panel"><h2>Связи узлов</h2>{{template "fabric-links" .Links}}</div>
|
||
</section>
|
||
<section class="panel"><h2>Route intents</h2><table><tbody>{{range .Routes}}<tr><td><strong>{{.ServiceClass}}</strong><small>{{.ID}}</small></td><td>{{.Priority}}</td><td><span class="pill {{.Status}}">{{.Status}}</span></td></tr>{{else}}<tr><td>route intents не найдены</td></tr>{{end}}</tbody></table></section>
|
||
</section>`))
|
||
webControlHTMLTemplate.Funcs(template.FuncMap{"dict": templateDict})
|
||
template.Must(webControlHTMLTemplate.New("web-control-fragment").Parse(`
|
||
<section id="web-control-fragment">
|
||
{{if .Result}}<div class="result {{.Result.Tone}}"><strong>{{.Result.Title}}</strong><span>{{.Result.Message}}</span></div>{{end}}
|
||
<div class="panel"><table><thead><tr><th>Узел</th><th>Admin ingress</th><th>Public ingress</th></tr></thead><tbody>
|
||
{{range .Rows}}<tr>
|
||
<td><strong>{{.NodeName}}</strong><small><span class="pill {{.FreshTone}}">{{.Freshness}}</span></small></td>
|
||
<td>{{template "web-control-service" dict "Root" $ "Node" . "Service" .Admin}}</td>
|
||
<td>{{template "web-control-service" dict "Root" $ "Node" . "Service" .Public}}</td>
|
||
</tr>{{else}}<tr><td colspan="3">узлы не найдены</td></tr>{{end}}
|
||
</tbody></table></div>
|
||
</section>`))
|
||
template.Must(webControlHTMLTemplate.New("web-control-service").Parse(`
|
||
<form class="miniForm" hx-post="/api/v1/clusters/{{.Root.Cluster.ID}}/ui/web-control/desired?actor_user_id={{.Root.ActorUserID}}" hx-target="#web-control-fragment" hx-swap="outerHTML">
|
||
<input type="hidden" name="node_id" value="{{.Node.NodeID}}">
|
||
<input type="hidden" name="service_type" value="{{.Service.ServiceType}}">
|
||
<div class="stateLine"><span class="pill {{.Service.DesiredTone}}">{{.Service.DesiredState}}</span><span class="pill {{.Service.ReportedTone}}">{{.Service.ReportedState}}</span></div>
|
||
<div class="formRow"><select name="desired_state"><option value="enabled">enabled</option><option value="disabled">disabled</option></select><select name="runtime_mode"><option value="container">container</option><option value="native">native</option></select><input name="version" value="{{.Service.Version}}" placeholder="version"><button type="submit">Сохранить</button></div>
|
||
<small>{{.Service.RuntimeMode}} · {{.Service.ObservedAt}}</small>
|
||
</form>`))
|
||
}
|
||
|
||
func templateDict(values ...any) map[string]any {
|
||
out := map[string]any{}
|
||
for i := 0; i+1 < len(values); i += 2 {
|
||
key, _ := values[i].(string)
|
||
if key != "" {
|
||
out[key] = values[i+1]
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func templateAdd(left, right int) int {
|
||
return left + right
|
||
}
|
||
|
||
func templateMid(left, right int) int {
|
||
return (left + right) / 2
|
||
}
|
||
|
||
func templateInitial(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "?"
|
||
}
|
||
return strings.ToUpper(string([]rune(value)[0]))
|
||
}
|
||
|
||
func consoleHTMLHead(title string) string {
|
||
return `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>` + 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:1360px;margin:0 auto;padding:16px}.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}.hero{display:flex;justify-content:space-between;gap:12px;align-items:end;margin-bottom:12px}.hero h1{margin:0;font-size:1.45rem}.hero p,.muted,small{color:#667064}.metrics{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;margin-bottom:10px}.insightGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:10px}.metric,.panel,.insight{border:1px solid #1a271b26;border-radius:8px;background:#fffdf5}.metric,.insight{padding:10px}.metric span,.insight span{display:block;color:#667064;font-size:.74rem;font-weight:800}.metric strong,.insight strong{display:block;margin-top:3px;font-size:1.22rem}.metric small,.insight small,td small{display:block;margin-top:3px}.layout2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px;margin-bottom:10px}.panel{padding:10px;overflow:hidden}h2{font-size:.96rem;margin:0 0 8px}table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid #1a271b1f;text-align:left;vertical-align:top;padding:8px;font-size:.84rem}th{font-size:.72rem;text-transform:uppercase;color:#667064;background:#f8f7ef}.pill{display:inline-flex;width:fit-content;border-radius:8px;padding:.22rem .48rem;font-size:.72rem;font-weight:850;background:#36556c1a;color:#36556c}.good,.ready,.healthy,.active,.enabled,.running{color:#236c4a;background:#2f6f4f1f}.warn,.degraded,.pending,.disabled{color:#9a5b1c;background:#b86f2324}.bad,.failed,.blocked{color:#a64235;background:#b144341f}.muted{color:#667064}.links{display:grid;gap:8px}.link{display:grid;grid-template-columns:minmax(80px,1fr) 54px minmax(80px,1fr);gap:8px;align-items:center}.link small{grid-column:1/4}.line{height:2px;border-radius:10px;background:#36556c55}.line.good{background:#2f6f4f}.line.warn{background:#b86f23}.line.bad{background:#b14434}.kv{display:grid;grid-template-columns:140px minmax(0,1fr);gap:8px}.kv span{color:#667064}.policyGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:10px}.miniForm{display:grid;gap:6px;border:1px solid #1a271b1f;border-radius:8px;padding:8px;background:#ffffff80}.stateLine,.formRow{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.filterBar{display:grid;gap:8px}.filterTitle{display:flex;justify-content:space-between;gap:8px;align-items:center}.filterGroups{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:8px}.filterGroups fieldset{border:1px solid #1a271b1f;border-radius:8px;margin:0;padding:8px;background:#ffffff80}.filterGroups legend{font-size:.78rem;font-weight:850;color:#667064;padding:0 4px}.filterBar label{display:flex;justify-content:space-between;gap:6px;align-items:center;font-size:.84rem;padding:3px 0}.filterBar label span{color:#667064;font-size:.74rem}.legend{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:6px;margin-top:8px}.legend span{font-size:.78rem;color:#667064}.legend i{display:inline-block;width:18px;height:2px;margin-right:6px;vertical-align:middle;background:#667064}.legend i.good{background:#2f6f4f}.legend i.warn{background:#b86f23}.legend i.bad{background:#b14434}select,input,button{min-height:32px;border:1px solid #1a271b26;border-radius:8px;background:#fffdf5;color:#172019;padding:.35rem .5rem}input{max-width:220px}button{font-weight:800;cursor:pointer}.result{display:grid;gap:3px;border-radius:8px;padding:10px;margin-bottom:10px}.topologyCanvas{width:100%;overflow:auto}.topologyCanvas svg{display:block;width:100%;min-width:820px;height:auto}.topologyBg{fill:#f8f7ef;stroke:#1a271b1a;stroke-width:1}.edge{fill:none;stroke:#36556c66;stroke-width:1.45;stroke-linecap:round;stroke-linejoin:round;opacity:.82}.edge:hover{stroke-width:2.6;opacity:1}.edge.good{stroke:#2f6f4fcc}.edge.warn{stroke:#b86f23bb}.edge.bad{stroke:#b14434bb}.edge.muted{stroke:#66706488}.edge.route{stroke-dasharray:6 7}.edge.directory{stroke-dasharray:2 6;opacity:.55}.edge.endpoint{stroke-width:1.1;opacity:.5}.edge.rendezvous{stroke-dasharray:9 5 2 5}.edge.recovery{stroke-dasharray:3 5}.edge.transport-direct{stroke-width:1.65}.edge.transport-private-lan{stroke:#2f6f4fcc;stroke-width:1.55}.edge.transport-relay{stroke-dasharray:9 5 2 5;stroke-width:1.7}.edge.transport-unknown{stroke-dasharray:4 5}.edge.transport-suspicious{stroke:#b14434;stroke-width:2.05}.arrow{stroke:none;fill:#36556c}.arrow.good{fill:#2f6f4f}.arrow.warn{fill:#b86f23}.arrow.bad{fill:#b14434}.arrow.muted{fill:#667064}.nodeCircle{stroke:#1720192b;stroke-width:1.4;fill:#fffdf5;filter:drop-shadow(0 6px 12px #17201918)}.nodeCircle.good{fill:#eef8f1;stroke:#2f6f4f}.nodeCircle.warn{fill:#fff4e8;stroke:#b86f23}.nodeCircle.bad{fill:#fff0ed;stroke:#b14434}.nodeInitial{text-anchor:middle;dominant-baseline:central;font-size:21px;font-weight:850;fill:#172019}.nodeName{text-anchor:middle;font-size:12px;font-weight:850;fill:#172019}.nodeMeta{text-anchor:middle;font-size:10px;fill:#667064}@media(max-width:860px){main{padding:10px}.metrics,.layout2,.insightGrid,.filterGroups{grid-template-columns:1fr}.hero{display:grid}.link{grid-template-columns:1fr}.link small{grid-column:auto}}</style></head>`
|
||
}
|