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

1486 lines
71 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cluster
import (
"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>`
}